vM.

Fixing Slow Serialization of Large Nested Pydantic Responses in FastAPI

Author
Vishal Maurya
Published on
Reading time
5 min read

Overview

The symptom is familiar: the SQL query takes 20 ms, but the endpoint takes over a second to respond and a CPU core sits at 100% while it does. The payload is a large list of nested objects, and the time is going into Python, not the database. In FastAPI that work is usually validation and serialization of the response.

This guide shows how to confirm that diagnosis and which changes actually reduce the cost.

1. Profile Before Changing Anything

Measure each stage separately so you know what you are optimizing. Time the query, the conversion to Pydantic models, and the JSON encoding independently.

import time

from pydantic import TypeAdapter

adapter = TypeAdapter(list[OrderOut])

t0 = time.perf_counter()
rows = repo.fetch_orders(limit=5000)          # database + ORM loading
t1 = time.perf_counter()
models = [OrderOut.model_validate(r) for r in rows]   # validation
t2 = time.perf_counter()
payload = adapter.dump_json(models)           # serialization
t3 = time.perf_counter()

print(f"query={t1 - t0:.3f}s validate={t2 - t1:.3f}s dump={t3 - t2:.3f}s bytes={len(payload)}")

For a deeper view, run the app under py-spy or wrap the handler with cProfile. Also check the response size. A 40 MB JSON document is slow to build, slow to transfer, and slow to parse in the browser, regardless of how fast your serializer is.

2. Understand Where response_model Adds Work

When you declare a response_model or a return type annotation, FastAPI validates and filters what your function returns before encoding it. If your handler already builds Pydantic models, the data may be processed more than once: once when you construct the models, and again when FastAPI handles the response. The exact behavior differs between FastAPI and Pydantic versions, so profile on your own versions rather than assuming.

Two things are worth checking:

  • Are you returning ORM objects or dictionaries that FastAPI must validate from scratch on every request?
  • Are you constructing Pydantic models manually and then letting FastAPI re-validate them?

3. Validate Once with from_attributes

With SQLAlchemy objects, configure the model to read attributes directly and convert in a single pass.

from datetime import datetime

from pydantic import BaseModel, ConfigDict


class ItemOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    sku: str
    price_cents: int


class OrderOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    created_at: datetime
    items: list[ItemOut]


@router.get("/orders", response_model=list[OrderOut])
def list_orders(limit: int = 100, session: Session = Depends(get_session)):
    stmt = select(Order).options(selectinload(Order.items)).limit(limit)
    return session.scalars(stmt).all()

Here the handler returns ORM objects and FastAPI performs one validation pass using from_attributes. Note the selectinload: if relationships load lazily inside serialization, you get an N+1 query pattern that looks like a serialization problem but is really a database one.

4. Return Less Data

The cheapest field to serialize is the one you do not send. Create dedicated list and detail schemas.

  • A list endpoint returns summary fields only. Move heavy nested collections to a detail endpoint.
  • Exclude fields the client never reads. Large text blobs and audit metadata are common offenders.
  • Avoid deeply nested structures where a flat list with IDs would do.

Cutting a payload in half usually helps more than any serializer tuning.

5. Paginate Every Unbounded List

An endpoint that returns all rows works until the table grows. Add limit and a cursor or offset, enforce a maximum, and return a bounded page.

from fastapi import Query


@router.get("/orders", response_model=list[OrderOut])
def list_orders(
    limit: int = Query(100, ge=1, le=500),
    after_id: int | None = None,
    session: Session = Depends(get_session),
):
    stmt = select(Order).order_by(Order.id).limit(limit)
    if after_id is not None:
        stmt = stmt.where(Order.id > after_id)
    return session.scalars(stmt.options(selectinload(Order.items))).all()

Keyset pagination (after_id) stays fast on deep pages, unlike large offsets.

6. Consider a Faster JSON Encoder Last

Pydantic v2 serializes in Rust, so the encoder is often not the bottleneck. If your profile shows JSON encoding dominating after the steps above, an alternative such as orjson may help. FastAPI has offered ORJSONResponse, but its status has changed across releases, so check the documentation for your installed version before relying on it. An option that does not depend on that class is to serialize with a Pydantic TypeAdapter and return a plain Response.

from fastapi import Response
from pydantic import TypeAdapter

_adapter = TypeAdapter(list[OrderOut])


@router.get("/orders/export")
def export_orders(session: Session = Depends(get_session)):
    rows = session.scalars(select(Order).limit(500)).all()
    body = _adapter.dump_json(_adapter.validate_python(rows, from_attributes=True))
    return Response(content=body, media_type="application/json")

This skips a second pass but also skips response_model filtering, so only use it for schemas you control.

Conclusion

Slow large responses are rarely fixed by one trick. Measure first, validate once, send less data, paginate, and only then consider swapping encoders. Re-measure after each change so you keep only what helps.

If a FastAPI endpoint is slow on large payloads and you want a second pair of eyes on the profile, I can help find the real bottleneck and fix it. Contact me.

Additional Resources

  • FastAPI response model documentation
  • Pydantic serialization documentation
  • Pydantic models and from_attributes