Nested Model Serialization in FastAPI
Serialization looks like validation running backwards. It is not. It is a second, independent traversal of your model with its own compiled program, its own schema, and its own rules about what a value turns into — and most nested-model surprises come from assuming the two directions are symmetrical.
This guide is part of Advanced Pydantic Validation and Serialization. It covers the model you need to predict what comes out of a nested structure and how to decide what shape your responses should take. The cost of walking a large graph, per-endpoint field selection, and models that nest into themselves each have their own page beneath this one.
Prerequisites
You should be comfortable composing models — a model used as another model's field type — and know that FastAPI's response_model governs the response shape. It also helps to have read the custom validators guide, because serializers are declared with a matching set of decorators and it is useful to know which direction each one applies to.
Everything here assumes Pydantic v2. If you are still migrating, the serialization method names and the json_encoders replacement are both covered in the Pydantic V2 Migration Guide.
Core Mechanics: Two Programs, Not One Pipeline
When a model class is defined, Pydantic compiles two things from it: a validator and a serializer. They are built from the same field declarations but they are separate programs, and neither is the inverse of the other.
This asymmetry is not a quirk; it falls out of what each direction has to do. Validation receives untyped data and must decide whether it is acceptable, so it needs constraints, coercion rules and your validators. Serialization receives an object whose types are already guaranteed and must decide how to represent it, so it needs no checking at all — only a rendering decision per node.
Because the two directions describe different things, a model has two JSON Schemas. This is not a detail you can ignore, because FastAPI uses both: the validation schema documents your request bodies and the serialization schema documents your responses.
The mode that catches everyone
model_dump() does not produce JSON. It produces a Python dictionary, and by default it stays in python mode, which means every value keeps its rich Python type. Nested models become dictionaries, but a datetime stays a datetime.
The example builds one nested Order containing a LineItem containing a Product, with a datetime, a date, a UUID and a Decimal distributed through it, then reports the types that come out of each mode:
class Product(BaseModel):
id: UUID
name: str
price: Decimal
class LineItem(BaseModel):
product: Product
quantity: int
class Order(BaseModel):
id: int
placed_at: datetime
ship_by: date
items: list[LineItem]
The recorded output:
$ GET /python-mode
200 OK
{
"placed_at_type": "datetime",
"ship_by_type": "date",
"product_id_type": "UUID",
"price_type": "Decimal",
"items_container": "list"
}
$ GET /json-mode
200 OK
{
"placed_at": "2026-07-20T09:30:00Z",
"ship_by": "2026-07-24",
"product_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"price": "19.99",
"price_type": "str"
}
$ GET /dumps-fails
200 OK
{
"json_dumps": "TypeError",
"message": "Object of type datetime is not JSON serializable"
}
The UUID three levels down is still a UUID object. That is what makes the third result inevitable: handing python-mode output to json.dumps raises TypeError: Object of type datetime is not JSON serializable, and it raises it from wherever the first non-JSON type happens to sit in the graph, which on a deep model can be a long way from the code you are looking at.
Two of the JSON-mode conversions deserve attention. The Decimal becomes the string "19.99", not the number 19.99 — Pydantic v2 preserves decimal precision by refusing to route it through a float. If you are migrating from a v1 service that used a json_encoders entry to emit a number, that is a wire-format change your type checker will not flag, and Replacing json_encoders with field_serializer covers how to control it. The datetime renders as RFC 3339 with a Z suffix, which is what most clients expect but is worth pinning in a test if any consumer parses it strictly.
The rule that follows is simple. If the output is going into a response, use model_dump_json(). If it is going into further Python code, use model_dump(). If it is going into another JSON encoder — a logging formatter, a message broker payload, a cache — use mode="json". The one combination that is always wrong is python mode followed by a separate JSON encoding step.
That the JSON-mode dict and the encoded string agree is worth verifying rather than assuming:
$ GET /round-trip
200 OK
{
"identical_to_json_mode": true,
"document": {
"id": 7,
"placed_at": "2026-07-20T09:30:00Z",
"ship_by": "2026-07-24",
"items": [
{
"product": {
"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"name": "Widget",
"price": "19.99"
},
"quantity": 3
}
]
}
}
Production Implementation: Deciding the Output Shape
Nested responses go wrong in two directions: they expose fields nobody meant to publish, and they grow until a single response carries a customer's entire history. Both are shape decisions, and Pydantic gives you three tools with genuinely different trade-offs.
A dedicated response model
The default answer. Declare a model containing exactly the fields the endpoint returns, and set it as response_model.
class ProductSummary(BaseModel):
id: UUID
name: str
class OrderSummary(BaseModel):
id: int
placed_at: datetime
products: list[ProductSummary]
@router.get("/orders/{order_id}", response_model=OrderSummary)
async def read_order(order_id: int) -> OrderSummary:
...
The contract lives in a type, so it appears in your OpenAPI document, a client generator can produce a class for it, and a reviewer can see what the endpoint publishes without running it. Its cost is real: a service with many views of one entity accumulates many near-identical models.
Aliases and computed fields
Aliases rename fields on the wire without renaming your attributes, and computed fields add derived values that were never inputs:
class Order(BaseModel):
id: int = Field(serialization_alias="orderId")
items: list[LineItem]
@computed_field
@property
def total_quantity(self) -> int:
return sum(item.quantity for item in self.items)
A computed field is the clearest demonstration of the two-schema mechanic. It cannot be supplied by a caller, so it does not exist in the validation schema; it is always produced, so it always exists in the serialization schema. Because it is a property, it is evaluated during the dump — meaning an expensive computation inside one is paid on every serialization, and a computed field that queries or aggregates is a latency problem hiding inside a response model.
Note also that validation aliases and serialization aliases are separate settings. Setting only one gives you an endpoint that accepts orderId and returns id, or the reverse, and round-tripping your own response back into your own model then fails.
Per-request field selection
When the shape genuinely varies per call — a client asking for a subset, an internal caller needing more detail than a public one — the response_model_exclude and response_model_include arguments select fields per route, including into nested models. The important limitation is that these arguments do not change the generated schema, so a shape you express this way is undocumented. Excluding Fields Per Endpoint in FastAPI works through each variant and measures what exclusion does and does not save you.
Serializing graphs that came from an ORM
Most nested responses in a real service are not built by hand; they are built from database rows. That introduces two problems which look like serialization bugs and are not.
The first is attribute access. An ORM row is not a dictionary, so a model that should be populated from one needs model_config = ConfigDict(from_attributes=True) and model_validate(row). Without it you get a model_type error complaining that the input is not a valid dictionary, which reads as though the data is malformed when the real issue is how the model was told to read it.
The second is more expensive and much harder to see. If your ORM lazy-loads relationships, then serializing a nested model is what triggers the loading — and it triggers one query per parent object. A list endpoint returning fifty orders, each with a nested customer, will issue a query per order during serialization, and the slow frame in your profile will be inside the serializer rather than inside your repository. The serializer is not slow; it is being used as a query trigger.
class OrderRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
customer: CustomerRead
items: list[LineItemRead]
# Load the relationships the response model declares, in one query, before serializing.
stmt = (
select(Order)
.options(selectinload(Order.items), joinedload(Order.customer))
.where(Order.id == order_id)
)
The rule that avoids this is to make the response model and the query agree: every nested model in the response shape needs a corresponding eager load. When they drift apart, the symptom is a response that gets slower as data grows, with no change to the serialization code. Reproducing it in tests requires a real database session rather than a fixture of fully-populated objects — the technique is covered in Testing with Async Database Fixtures.
A related trap is serializing a model whose relationship was never loaded and whose session has already closed, which raises at render time from deep inside the handler. Detaching objects from the session before returning them, or converting to Pydantic models while the session is open, both avoid it; see Async SQLAlchemy Session Per Request.
When the nesting has no fixed depth
Comment threads, category trees and organisational hierarchies nest into themselves, which needs a model that refers to its own type. Pydantic v2 resolves self-references automatically, but there is a real ceiling on how deep a structure it will validate, and hitting it produces a specific error you should recognise. Self-Referencing and Recursive Models in Pydantic v2 has the mechanics and the measured limit.
Performance Notes
Serialization is CPU work on the event loop thread. A handler that awaits nothing while it renders a large graph occupies its worker for the whole of that render, and other requests on the same worker wait. This is the main reason response size is a latency concern and not only a bandwidth one.
The dominant term is the number of nodes in the graph, which is why a shape decision is usually a bigger performance lever than any serialization setting. A response holding an unbounded nested collection has a cost set by whichever record happens to have the most children, and that is not a property you control. Paginating nested collections and choosing narrower models are the two interventions that actually change the number, and Handling Deeply Nested JSON Models Efficiently measures where the work goes and how much of it is avoidable.
One avoidable cost is worth naming here because it is created by a well-intentioned line of code: calling model_dump() in your handler and returning the resulting dictionary. FastAPI still has to produce the declared response_model, so it validates your dictionary back into the model before serializing it — work that returning the model directly would have skipped entirely. Return the instance and let the framework do the rendering.
For responses that are expensive and change rarely, the effective answer is not to serialize faster but to serialize less often; see Caching Strategies.
Testing Strategy
Serialization tests should pin the wire contract, because that contract is what your consumers are coupled to and it is invisible in the model declaration once aliases and computed fields are involved.
def test_order_wire_contract():
order = Order(id=1, items=[LineItem(product=Product(id=UUID(int=9), name="X"), quantity=3)])
data = order.model_dump(mode="json", by_alias=True)
assert data["orderId"] == 1
assert data["total_quantity"] == 3
assert isinstance(data["items"][0]["product"]["id"], str)
Three habits make these tests earn their keep. Assert in mode="json", since that is the representation clients receive and the only one where a type change is visible. Include a nested field in every assertion, because shape regressions usually happen a level or two down where nobody is looking. And add a test that the response contains no unexpected keys, not just that it contains the expected ones — that is the assertion which catches a newly added internal field leaking out through an over-broad response model.
At the endpoint level, TestClient gives you the bytes FastAPI actually sends, which is the only place aliasing, exclusion and response_model interact.
Failure Modes and Diagnosis
TypeError: Object of type X is not JSON serializable. Diagnosis: python-mode output handed to a JSON encoder. Switch to mode="json" or model_dump_json(). The type named in the message tells you which field, but not where in the graph it sits.
A field appears in responses that should never leave the service. Diagnosis: a response model built from an ORM-backed or internal model inherits every field. Declare response models explicitly rather than reusing storage models, and assert on the exact key set in tests.
Numbers arriving at the client as strings. Diagnosis: a Decimal field. This is deliberate, to preserve precision. Change it with a field serializer if your consumers need a JSON number, and treat the change as a contract change.
A round trip through your own API fails validation. Diagnosis: a serialization alias with no matching validation alias, so the name you emit is not the name you accept.
Latency scales with the size of one customer's data. Diagnosis: an unbounded nested collection in the response. Paginate the nested resource behind its own endpoint.
Response time worse after adding a computed field. Diagnosis: the property does real work and is re-evaluated on every dump. Compute it once at construction and store it as an ordinary field if the input is stable.
Choosing a Shaping Tool
| Dedicated response model | Alias / computed field | response_model_exclude | |
|---|---|---|---|
| Shape is | fixed per endpoint | fixed per model | variable per request |
| Appears in OpenAPI | yes | yes | no |
| Visible in code review | yes, as a type | yes, on the model | only at the route |
| Reduces nodes serialized | yes | no | yes |
| Cost | more model classes | applies everywhere the model is used | undocumented contract |
| Reach for it when | the shape is part of your API | the wire name or a derived value differs | a caller chooses the fields |
The default should be the first column. The third column is right when the variability is genuine, and wrong when it is being used to avoid writing a second model — that is how a permanent, undocumented response shape gets created one route argument at a time.
FAQ
Why does json.dumps fail on the output of model_dump?
Because model_dump defaults to python mode, which preserves rich Python types. A datetime field stays a datetime object and a UUID stays a UUID, and the standard library encoder cannot serialize either. Use model_dump(mode="json") to get JSON-native values, or model_dump_json() to get the encoded string directly.
Is serialization the same traversal as validation, in reverse? No. They are two separate compiled programs over the same model. Validation turns untyped input into typed attributes and runs your validators; serialization turns typed attributes into output and runs your serializers. A model has two JSON Schemas for this reason, and they do not contain the same fields.
Why does my computed field not appear in the request schema? Because a computed field only exists on the way out. It appears in the serialization schema and in every dump, but not in the validation schema, since a caller cannot supply it. FastAPI uses the validation schema for request bodies and the serialization schema for responses, so the two shapes legitimately differ.
Should I shape nested responses with exclude arguments or with separate models? Use a separate model when the shape is a stable part of your API, because the contract is then visible in the type and in your OpenAPI document. Use exclude arguments when the shape varies per request, such as a client-supplied field selection. Exclusion arguments do not change the published schema, so a permanent shape expressed that way is undocumented.
Does returning a Pydantic model from an endpoint validate it a second time?
Not when you return an instance of the declared response_model, which Pydantic recognises and passes through. You create the extra work by calling model_dump yourself and returning the dict, because FastAPI must then validate that dict back into the model before serializing it.
Related Reading
- Up to the section: Advanced Pydantic Validation and Serialization for how serialization relates to validation and schema generation.
- Where the traversal cost goes on a large graph: Handling Deeply Nested JSON Models Efficiently.
- Per-route field selection, and what it actually saves: Excluding Fields Per Endpoint in FastAPI.
- Models that nest into themselves, and the depth ceiling: Self-Referencing and Recursive Models in Pydantic v2.
- How both schemas reach your OpenAPI document: JSON Schema Customization.