Handling Deeply Nested JSON Models Efficiently

Key takeaways:

  • Cost scales with the number of model instances in the graph, not with its depth.
  • Returning a model instance skips response-model re-validation; returning a dict does not.
  • A dict return doubled both validation and serialization work in the measurement below.
  • computed_field runs once per node per dump and is never cached between dumps.
  • Page unbounded child collections instead of embedding them; that is the only fix that scales.

This guide is about the cost of a nested graph, and where the passes over it come from. It sits under Nested Model Serialization alongside two guides on shaping such graphs: excluding fields per endpoint and self-referencing and recursive models.

The Problem This Solves

A nested response feels free while the nesting is small. An order with three line items serializes in microseconds. The same endpoint, once real customers have orders with two thousand line items, spends real CPU on the event loop for every request, and because that CPU is synchronous work inside an async def, it blocks every other request on the worker while it runs.

The instinct at that point is to optimise the serializer. Usually the serializer is fine and the graph is being walked more times than anybody realised.

Why It Happens: Every Pass Visits Every Node

Pydantic v2 does its work in a compiled core, so a single pass over a graph is fast. What is expensive is doing several passes without knowing it.

A request that posts a nested body and returns a nested response has at least two obvious passes: validating the incoming JSON into models, and serializing the outgoing models into JSON. Both are unavoidable and both are O(nodes).

The interesting question is whether there is a third: does FastAPI re-validate what your endpoint returns, against response_model, before serializing it? That would triple the work on the most common endpoint shape in the framework. The received wisdom is that it does. Rather than repeat that, the example below counts.

Depth, incidentally, is a red herring. A graph three levels deep with twelve leaves costs the same as a flat list of twelve — the walk visits each node once either way. What matters is the node count, which is why an unbounded child collection is the thing that actually hurts.

How many times one request walks the nested graphReturning a model instance costs one validation pass on the request body and one serialization pass on the response. Returning a dict adds a second validation pass over every node, doubling the work.Returning the model: two passesvalidate body12 leaves visitedinstance short-circuit0 leaves visitedserialize12 leaves visitedReturning a dict: the graph is rebuiltvalidate body12 leaves visitedre-validate dict12 more, all wastedserialize12 leaves visited24 validations for a 12-leaf payload

Prerequisites

  • Pydantic v2. The measurements below are Pydantic 2.13.4 on FastAPI 0.139.2, Python 3.12.
  • A payload with a realistic node count. Three items will not reveal anything.

The Fix: Count the Passes

A counter on a field validator fires once per leaf per validation pass; a counter inside a computed_field fires once per leaf per serialization pass. Both are instrumented here, and the endpoints differ only in what they return.

COUNTS = {"leaf_validated": 0, "leaf_serialized": 0, "computed_field_calls": 0}


class Line(BaseModel):
    sku: str
    qty: int
    unit_price: float

    @field_validator("sku")
    @classmethod
    def _count(cls, v: str) -> str:
        COUNTS["leaf_validated"] += 1     # Fires once per leaf, per validation pass.
        return v

    @computed_field
    @property
    def total(self) -> float:
        COUNTS["computed_field_calls"] += 1   # Fires on every serialization pass.
        return round(self.qty * self.unit_price, 2)


class Box(BaseModel):
    label: str
    lines: list[Line]


class Shipment(BaseModel):
    ref: str
    boxes: list[Box]


# response_model equals the input model: the returned object is validated a SECOND time.
@app.post("/shipments", response_model=Shipment)
async def create_shipment(shipment: Shipment) -> Shipment:
    return shipment


# No response_model and no return annotation: FastAPI has nothing to validate against.
@app.post("/shipments-raw")
async def create_shipment_raw(shipment: Shipment):
    return shipment


# Returning a plain dict gives FastAPI no instance to short-circuit on, so the whole graph is
# validated a second time to build the response model.
@app.post("/shipments-dict", response_model=Shipment)
async def create_shipment_dict(shipment: Shipment):
    return shipment.model_dump()


class StrictShipment(Shipment):
    # revalidate_instances="always" opts out of the instance short-circuit on purpose.
    model_config = {"revalidate_instances": "always"}


@app.post("/shipments-revalidate", response_model=StrictShipment)
async def create_shipment_revalidate(shipment: StrictShipment) -> StrictShipment:
    return shipment

Each variant is sent the same payload — three boxes of four lines, so twelve leaf models — with the counters reset in between:

$ GET /selftest
200 OK
{
  "leaves_in_payload": 12,
  "with_response_model": {
    "leaf_validated": 12,
    "leaf_serialized": 0,
    "computed_field_calls": 12
  },
  "without_response_model": {
    "leaf_validated": 12,
    "leaf_serialized": 0,
    "computed_field_calls": 12
  },
  "returning_a_dict": {
    "leaf_validated": 24,
    "leaf_serialized": 0,
    "computed_field_calls": 24
  },
  "revalidate_instances_always": {
    "leaf_validated": 12,
    "leaf_serialized": 0,
    "computed_field_calls": 12
  },
  "manual": {
    "after_model_validate": {
      "leaf_validated": 12,
      "leaf_serialized": 0,
      "computed_field_calls": 0
    },
    "after_first_dump": {
      "leaf_validated": 12,
      "leaf_serialized": 0,
      "computed_field_calls": 12
    },
    "after_second_dump": {
      "leaf_validated": 12,
      "leaf_serialized": 0,
      "computed_field_calls": 24
    }
  }
}

What the counters overturn

response_model does not re-validate an instance. with_response_model records 12 validations for 12 leaves — one pass, not two. Declaring a response_model identical to the input model costs nothing extra, because Pydantic short-circuits when the value is already an instance of the target class. The widespread belief that FastAPI validates your response a second time is, for this shape, simply wrong. Removing response_model for performance reasons gives up your response contract and your OpenAPI schema in exchange for nothing.

Returning a dict is what doubles the work. returning_a_dict records 24 validations and 24 computed-field calls, exactly double. A dict carries no type information, so there is nothing to short-circuit on and the entire graph is rebuilt from scratch. This is the real finding: the expensive pattern is not response_model, it is return something.model_dump() — a line people write to "help" FastAPI, which does the opposite.

revalidate_instances="always" did not change this case. It still records 12. The setting governs revalidation of instances encountered as fields during a validation pass, and does not force the top-level response value through validation again. Worth knowing before reaching for it as a safety measure: in this position it is not doing what its name suggests.

Computed fields are recomputed on every dump. The manual block is unambiguous: 0 calls after validation, 12 after the first model_dump_json(), 24 after the second. Nothing is cached. A computed_field that performs a database lookup or a currency conversion multiplies that cost by node count and by dump count — and if it does I/O, it is doing blocking I/O inside serialization, on the event loop.

Shaping the Graph

Once the passes are down to the minimum, the only remaining lever is visiting fewer nodes.

Page the children. An order with two thousand line items should not return them inline. A separate GET /orders/{id}/items?limit=50 bounds the response at a size you chose rather than one your largest customer chose:

@router.get("/orders/{order_id}/items", response_model=list[LineOut])
async def list_items(order_id: int, limit: int = 50, offset: int = 0) -> list[LineOut]:
    return await fetch_items(order_id, limit=limit, offset=offset)

The awkward part is that this is an API change, so it lands best when the endpoint is designed rather than after the incident. If you must retrofit, add the paged endpoint first, migrate clients, then cap the embedded list.

Use a narrower model for list views. A list endpoint rarely needs the full graph. A summary model with a count instead of the nested collection turns an O(orders × lines) response into O(orders):

class OrderSummary(BaseModel):
    id: int
    line_count: int      # A number, not a nested list of line items.

Serialize in one pass. When you build the body yourself, model_dump_json() goes straight to JSON in the core. json.dumps(model.model_dump()) materialises an intermediate dict — an allocation per node — and re-encodes it in Python. Same result, more work, and the gap grows with node count.

Verification

Assert on pass counts rather than on wall-clock time. Timings vary by machine and make flaky tests; counts are exact:

def test_response_graph_is_walked_once(client, counters):
    counters.reset()
    client.post("/shipments", json=payload(boxes=3, lines_per_box=4))
    # 12 leaves, one validation pass. A regression here means someone started returning a dict.
    assert counters["leaf_validated"] == 12


def test_computed_fields_are_cheap():
    # Guards against someone putting a query behind a computed_field.
    assert not any(inspect.iscoroutinefunction(f) for f in computed_fields_of(Line))

The first test is the valuable one, and it is worth adding to any endpoint that is on a hot path. It fails the moment someone refactors the return value into a dict — a change that looks harmless in review and doubles the endpoint's CPU.

For a running service, the signal is CPU time that scales with response size rather than with request rate. If p99 latency tracks the size of the largest customer's data, you have a node-count problem and no amount of serializer tuning will fix it.

Trade-offs and When Not To

Paging moves work to the client. Five requests instead of one means five round trips, and a mobile client on a slow link may prefer the single large response. Page when the collection is unbounded, not merely because it is nested.

Summary models multiply your types. OrderSummary, OrderDetail and OrderInternal are three things to keep in sync, and a field added to one and forgotten in another is a real bug. That cost is worth paying on hot list endpoints and not worth paying everywhere.

Do not remove response_model to go faster. The measurement above shows there is no gain, and you would lose response filtering, the documented contract, and the guarantee that internal fields cannot leak.

A cache may be the better answer. If the graph is expensive and changes rarely, caching the serialized bytes avoids every pass discussed here. See caching strategies for keeping such a cache correct.

FAQ

Does FastAPI validate my response a second time against response_model? Not when you return an instance of that model. Pydantic short-circuits on an instance of the expected class, so field validators do not run again. Return a dict instead and the entire graph is validated from scratch, which is where the second pass actually comes from.

Why is returning a dict from an endpoint slower than returning the model? Because a dict carries no type information, so Pydantic must validate every field at every level to build the response model. Measured on a twelve-leaf graph, returning a dict doubled both the validation and the serialization work compared with returning the model instance.

How often does a computed_field run? Once per leaf per serialization pass. It is recomputed on every dump rather than cached, so two dumps of the same object run it twice. Anything expensive behind a computed field is multiplied by your node count and by how many times you serialize.

Should I embed nested collections or link to them? Embed small bounded collections that every client needs, and page or link anything unbounded. An embedded list that grows without limit turns a fast endpoint into a slow one gradually, with no single change to blame.

Is model_dump_json faster than model_dump followed by json.dumps? Yes, because it serializes directly to JSON in the Rust core instead of materializing an intermediate Python dict and re-encoding it. The gap widens with node count, since the intermediate dict allocates an object per node.