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 —PydanticUserErroron 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 .