Cheatsheet for self-referencing / mutually-recursive Pydantic models.

Self-reference

class Comment(BaseModel):
    id: int
    text: str
    replies: list["Comment"] = Field(default_factory=list)

Comment.model_rebuild()

Quote the forward reference; call model_rebuild() after class definition.

Tree

class Category(BaseModel):
    id: int
    name: str
    children: list["Category"] = Field(default_factory=list)

Category.model_rebuild()

c = Category.model_validate({
    "id": 1, "name": "Root",
    "children": [
        {"id": 2, "name": "Sub", "children": []},
    ],
})

Mutually recursive

class A(BaseModel):
    name: str
    b: "B | None" = None

class B(BaseModel):
    name: str
    a: "A | None" = None

A.model_rebuild()
B.model_rebuild()

Forward refs via from __future__ import annotations

from __future__ import annotations

class Comment(BaseModel):
    id: int
    replies: list[Comment]    # no quotes needed with future annotations

Comment.model_rebuild()

Modern style; preferred.

Default depth

Pydantic doesn’t limit recursion depth in models — but Python’s stack does. For deep trees: bound depth.

@model_validator(mode="after")
def check_depth(self):
    def depth(n):
        if not n.children: return 1
        return 1 + max(depth(c) for c in n.children)
    if depth(self) > 100:
        raise ValueError("tree too deep")
    return self

Serialization (round-trip)

data = comment.model_dump()
restored = Comment.model_validate(data)

Works recursively.

Avoiding cycles

For graphs (not trees), cycles cause infinite serialization. Track visited:

def serialize_safe(node, seen=None):
    if seen is None: seen = set()
    if id(node) in seen:
        return {"$ref": node.id}
    seen.add(id(node))
    return {"id": node.id, "neighbors": [serialize_safe(n, seen) for n in node.neighbors]}

Pydantic doesn’t handle cyclic graphs out of the box.

JSON Schema

Recursive types appear via $ref in the schema:

{
  "$defs": {
    "Comment": {
      "type": "object",
      "properties": {
        "replies": {
          "type": "array",
          "items": {"$ref": "#/$defs/Comment"}
        }
      }
    }
  }
}

Tools like OpenAPI generators handle this.

Generic recursive

T = TypeVar("T")

class Tree(BaseModel, Generic[T]):
    value: T
    children: list["Tree[T]"] = Field(default_factory=list)

Tree.model_rebuild()

Common mistakes

  • Forgetting model_rebuild() after class definition — PydanticUserError on validate.
  • Cycles in graph — infinite recursion on dump.
  • Using Optional["X"] instead of "X | None" — works but less modern.
  • Deep recursion → Python RecursionError.

Read this next

If you want my tree + graph Pydantic patterns, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .