Self-Referencing and Recursive Models in Pydantic v2
Key takeaways:
- A model can reference itself by name in a string annotation:
replies: list["Comment"] = []. - Pydantic v2 usually resolves this automatically;
model_rebuild()returnsNonewhen nothing was left to do. - Forward references to types defined later in the module do need an explicit
model_rebuild(). - Too-deep input fails as a normal 422 with
type: recursion_loop— it does not crash the process. - On Pydantic 2.13.4 / CPython 3.12 at the default recursion limit of 1000, the measured ceiling was 255 levels.
This page extends nested model serialization to the case where the nesting has no fixed depth.
The Problem This Solves
Comment threads, org charts, category trees, file systems, nested rule expressions — a lot of real data is a tree whose depth is decided by users, not by you. Modelling it means a class that refers to itself, which is a problem in Python because the class does not exist while its own body is executing.
The follow-on questions are the ones that actually cost time: does it need model_rebuild(), does serialization recurse correctly, and what happens when someone posts a thread 5,000 levels deep.
Why It Happens
class Comment binds the name Comment only after the class body finishes. An annotation inside the body that says list[Comment] therefore raises NameError. Python's answer is a forward reference: write the name as a string, or turn on from __future__ import annotations so every annotation in the module becomes a string automatically.
That defers the problem rather than solving it, because Pydantic must eventually resolve the string to a real type to build the validation schema. In v1 you were required to call update_forward_refs() yourself. In v2 Pydantic attempts resolution as soon as the class body completes, and since Comment is bound by then, a self-reference resolves without help. model_rebuild() remains for the cases automatic resolution cannot handle — most commonly a reference to a class defined further down the module.
Prerequisites
- Pydantic 2.13.4, FastAPI 0.139.2, CPython 3.12 at the default recursion limit.
- Familiarity with handling deeply nested JSON models efficiently for the fixed-depth case.
Step-by-Step Implementation
1. Declare the self-reference
"""Self-referencing models: a comment tree, forward references, model_rebuild, depth limits."""
from __future__ import annotations
import sys
from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel, ValidationError
app = FastAPI()
class Comment(BaseModel):
id: int
body: str
replies: list["Comment"] = []
# With `from __future__ import annotations` every annotation is a string, so the self-reference
# resolves at rebuild time rather than class-creation time.
REBUILT = Comment.model_rebuild()
class Node(BaseModel):
"""A model whose forward reference points at a type defined later in the module."""
label: str
child: "Leaf | None" = None
class Leaf(BaseModel):
value: int
NODE_REBUILT = Node.model_rebuild()
Note the mutable default replies: list["Comment"] = []. In a dataclass that would be a bug; in Pydantic it is fine, because Pydantic deep-copies defaults per instance rather than sharing them.
2. Check whether the rebuild was needed
The /rebuild-status endpoint reports what each model_rebuild() call returned. Real output from _verify/output/pyd-recursive-models.txt:
$ GET /rebuild-status
200 OK
{
"Comment.model_rebuild()": null,
"Node.model_rebuild()": true,
"Comment fields": [
"id",
"body",
"replies"
],
"replies annotation": "list[example_pyd-recursive-models.Comment]",
"recursion_limit": 1000
}
The two return values tell the whole story of when you need the call.
Comment.model_rebuild() returned None, meaning the model was already complete. The self-reference resolved on its own the moment the class body finished, because Comment was bound in the module namespace by then. The annotation, printed above, is a fully resolved list[Comment] — not a dangling string.
Node.model_rebuild() returned True, meaning it actually rebuilt something. Node.child refers to Leaf, which is defined afterwards, so when Pydantic first tried to build Node's schema the name did not resolve. The model sat incomplete until model_rebuild() was called at a point where Leaf existed.
That is the rule in practice: self-references are free, forward references to later definitions are not. Since the call is idempotent and cheap, leaving it in after each group of mutually-referencing models is reasonable defensive style — it just usually returns None.
3. Validate and serialize a tree
$ POST /comments/ {"id": 1, "body": "Does exclude_unset apply to nested models?", "replies": [{"id": 2, "body": "Yes, it recurses.", "replies": [{"id": 4, "body": "With a caveat for defaults.", "replies": []}]}, {"id": 3, "body": "See the serialization docs.", "replies": []}]}
200 OK
{
"id": 1,
"body": "Does exclude_unset apply to nested models?",
"replies": [
{
"id": 2,
"body": "Yes, it recurses.",
"replies": [
{
"id": 4,
"body": "With a caveat for defaults.",
"replies": []
}
]
},
{
"id": 3,
"body": "See the serialization docs.",
"replies": []
}
]
}
Validation and serialization both recurse without any extra configuration, and response_model=Comment produces the full tree. The forward-referenced Node works the same way once rebuilt:
$ POST /nodes/ {"label": "root", "child": {"value": 9}}
200 OK
{
"label": "root",
"child": {
"value": 9
}
}
4. Read a nested error location
Errors deep in a tree carry a full path, which is what makes them debuggable:
$ POST /comments/ {"id": 1, "body": "bad", "replies": [{"id": "x", "body": "y"}]}
422 Unprocessable Entity
{
"detail": [
{
"type": "int_parsing",
"loc": [
"body",
"replies",
0,
"id"
],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "x"
}
]
}
loc interleaves field names and list indices all the way down. For a failure ten levels deep the loc is twenty-odd elements long — accurate, and worth truncating before it reaches a log aggregator.
The Depth Limit
A recursive model accepts a tree of any shape, but not of any depth. The example builds a single-child chain of a requested depth and validates it:
$ GET /depth/50
200 OK
{
"depth": 50,
"result": "ok",
"serialized_bytes": 1889
}
$ GET /depth/400
200 OK
{
"depth": 400,
"result": "ValidationError",
"type": "recursion_loop",
"msg": "Recursion error - cyclic reference detected",
"loc_length": 510
}
Depth 50 validates and serializes. Depth 400 does not — and the failure is a well-behaved ValidationError, not a RecursionError escaping into the request handler. Pydantic's core detects the runaway and converts it, so FastAPI returns 422 rather than a 500.
The message is worth reading carefully: "Recursion error - cyclic reference detected". The structure here contains no cycle at all; it is a plain 400-deep chain. Pydantic reports depth exhaustion using its cyclic-reference wording, so an alert triggered by this text will be misleading. The loc_length of 510 is the giveaway — the error path is 510 elements deep, which is not what a genuine cycle looks like.
A binary search for the exact ceiling:
$ GET /max-depth
200 OK
{
"deepest_validating_tree": 255,
"recursion_limit": 1000
}
255 levels on Pydantic 2.13.4 with CPython 3.12 at the default recursion limit of 1000. This is not a documented constant and should not be treated as one — it moves with the interpreter's recursion limit, with the shape of the model, and plausibly with the Pydantic version. Measure it in your own environment with your own model if the number matters. What you can rely on is that a ceiling exists, that it is in the low hundreds rather than the low thousands, and that exceeding it produces a 422.
Edge Cases and Gotchas
- The limit applies to any deeply nested model, not just recursive ones. A recursive type merely makes it reachable by user-supplied data, which turns a theoretical limit into an availability concern.
- Raising
sys.setrecursionlimit()is not a fix. It converts a clean 422 into a real risk of a C-stack segfault, which takes the worker down rather than returning an error. - Self-references in
Optionaland unions need the same treatment.parent: "Comment | None" = Noneis a forward reference, and a model whose parent and child link both ways builds an object graph the serializer will traverse repeatedly. - True cycles are a different failure. If you construct
a.replies = [b]andb.replies = [a]in Python and then serialize, you get the samerecursion_looperror — this time correctly named. Recursive models are fine; recursive object graphs are not serializable to JSON. - The OpenAPI schema uses a
$refback to the same component. That is valid and most generators handle it, but some client generators produce awkward or non-terminating output for self-referential schemas. Check your generator before committing to the shape — see customizing OpenAPI schema generation. - Per-endpoint exclusion works but is verbose. Dropping a field at every level needs
{"replies": {"__all__": {...}}}repeated for the depth you want to cover; see excluding fields per endpoint.
Verification
Pin the behaviour you rely on, including the failure:
def test_tree_round_trips():
tree = Comment.model_validate(TREE)
assert tree.replies[0].replies[0].id == 4
def test_excessive_depth_is_a_validation_error_not_a_crash():
with pytest.raises(ValidationError) as exc_info:
Comment.model_validate(nest(400))
assert exc_info.value.errors()[0]["type"] == "recursion_loop"
The second test is the one that matters in production: it asserts that hostile input produces a 422 rather than an unhandled exception, and it will fail loudly if a future Pydantic version changes that contract.
Trade-offs and When Not To
A recursive response model is elegant and it makes payload size a function of user-generated content. One popular thread produces a multi-megabyte body, serialized on the event loop, in a single response nobody can paginate. The depth ceiling then arrives as a 422 on exactly the content your users care most about.
For anything user-facing, cap it. Serve Comment with a bounded depth query parameter and a has_more_replies flag, or return a flat list of comments with parent_id and let the client assemble the tree — flat lists paginate, cache, and diff far better than trees. The recursive model is the right tool for configuration documents, rule expressions, and internal structures whose depth you control, and a liability for anything whose depth a stranger decides.
If you keep the tree, validate a maximum depth explicitly in a model validator so you reject at a depth you chose, with a message you wrote, rather than at 255 with a message about cyclic references.
FAQ
Do I still need model_rebuild() for self-referencing models in Pydantic v2?
Usually not. Pydantic v2 attempts to resolve forward references automatically when the class body finishes, and model_rebuild() returns None when the model was already complete. You need it when the referenced type is defined later in the module or imported conditionally, in which case calling it is harmless and explicit.
What error does a too-deep recursive structure produce?
A ValidationError with type recursion_loop and the message "Recursion error - cyclic reference detected". It is a normal validation error, so FastAPI returns 422 rather than crashing, but the message names cyclic references even when the structure is merely deep.
How deep can a recursive Pydantic model go? On Pydantic 2.13.4 with CPython 3.12 at the default recursion limit of 1000, a self-referencing comment tree validated to a depth of 255 and failed beyond that. The figure tracks the interpreter's recursion limit, so it is a property of the deployment rather than a documented constant.
Why does my forward reference fail with a class not fully defined error?
The referenced name did not exist when Pydantic tried to build the schema. Call Model.model_rebuild() after every referenced class is defined, and if the type lives in another module make sure it is imported at module scope rather than inside a TYPE_CHECKING block.
Should an API expose an unbounded comment tree? No. An unbounded recursive response makes payload size a function of user-generated data, so a single deep thread can produce a very large body. Paginate children or cap the depth server-side and let clients request deeper levels explicitly.
Related Reading
- Up to the topic: nested model serialization.
- For fixed-depth structures and the cost of serializing them, see handling deeply nested JSON models efficiently and Pydantic model serialization performance.
- Trimming what a tree emits per route is covered in excluding fields per endpoint.
- Self-referential
$refs in the generated document are discussed in customizing OpenAPI schema generation in FastAPI.