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() returns None when 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.

A self-referencing comment model and the tree it validatesThe Comment class has a replies field typed as a list of Comment, which produces a tree where each node can contain further nodes to a measured depth of 255 levels.class Commentid: intbody: strreplies: list[Comment]refers to itself by nameroot commentreplyreply to reply…measured ceiling: 255 levels
A single class describes a tree of unbounded shape, but not of unbounded depth — the validator itself has a limit.

Prerequisites

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 Optional and unions need the same treatment. parent: "Comment | None" = None is 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] and b.replies = [a] in Python and then serialize, you get the same recursion_loop error — this time correctly named. Recursive models are fine; recursive object graphs are not serializable to JSON.
  • The OpenAPI schema uses a $ref back 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.