Models inside models: composition, dicts that become objects, and error paths that reach all the way down.
Overview
Composition is the whole mechanism
There is no special syntax for nesting. A model is a type, and a field can be annotated with any type, so a field can be a model:
class Author(BaseModel):
name: str
email: str
class Module(BaseModel):
title: str
author: Author
That is it. Everything else follows from that one fact, which is worth stating plainly because people often expect a nested schema to need declaring somehow. It does not. If you can write the inner model on its own, you can use it as a field type.
When Module is validated, the value for author is handed to Author for validation. If it is already an Author, it passes through. If it is a dict, it becomes one. If it is neither, the error says so.
Worth knowing
A field annotated with a model validates that model too. Dicts on the way in become real objects, recursively.
loc is the route to the failure: ('author', 'email') is one level down, and it keeps working however deep the tree goes.
A nested model is validated with its own rules, including its own validators, defaults and constraints. Nothing is skipped because it is inside something else.
Use default_factory for a nested model that should always exist. A plain = Author() would share one instance across every parent.
model_dump() recurses into nested models, and its output is valid input to the same model again.
include and exclude take nested dicts — exclude={"author": {"email"}} drops one inner field and keeps the rest.
Nested Models: Structure That Validates Itself
Models inside models, and the error paths that make deep payloads debuggable.
A model can be a field type
Annotate a field with another model and Pydantic validates the inner one too. A plain dict on the way in becomes a real object on the way out.
example_01.pyPydantic
Output
Errors carry the whole path
This is where loc earns its design. The path names the route to the bad value, not just the top-level field that contained it.
example_02.pyPydantic
Output
Nesting goes as deep as you like
Three levels behaves exactly like two. The path simply gets longer, which is the point — it stays readable however deep the payload is.
example_03.pyPydantic
Output
Optional and defaulted nested models
The rules from the defaults module apply unchanged. A nested model can be absent, null, or given a default like anything else.
example_04.pyPydantic
Output
The rules from the defaults module apply without modification, and one of them has a specific trap here.
For a nested model that should always exist, use default_factory:
reviewer: Author = Field(default_factory=Author)
Not = Author(). That expression is evaluated once, when the class body runs, so every parent would share one Author instance — and since models are mutable by default, a change through one parent would be visible through all of them. Pydantic deep-copies simple defaults, but constructing the instance at class-definition time is still the wrong shape, and a factory says what you mean.
For a nested model that may genuinely be absent, Optional[Author] = None behaves exactly as it does for any other type, with the same distinction between omitted and explicitly null.
Note that a nested model whose own fields all have defaults can be built from an empty dict, which occasionally surprises people: Module(title="x", author={}) succeeds if every Author field has a default. That is correct, and it is a good reason to think about whether those inner defaults should exist.
Dumping and reloading a nested tree
model_dump() recurses, so the whole tree comes back as nested dicts — and that output is valid input again.
example_05.pyPydantic
Output
Trimming what comes out
exclude reaches into nested models with a dict, which is how you keep an internal field out of a public response.
example_06.pyPydantic
Output
Dicts in, objects out
The conversion from dict to object is the part that makes nesting useful rather than merely possible.
JSON has objects, and Python decodes them as dictionaries. Without nested models you would receive {"author": {"name": "Ada"}} and reach into it with data["author"]["name"] — two dictionary lookups, each of which can raise KeyError, neither of which your editor can help with.
With nesting you get m.author.name. It is checked, it is autocompleted, and a typo is an AttributeError at the point of the typo rather than a KeyError somewhere downstream.
The reverse works too: model_dump() recurses, and the result is nested plain dicts. So a model tree converts to and from JSON structure without you writing any of the walking.
Errors that name the exact value
Nesting is where the loc design pays off, and it is worth dwelling on because it is the difference between a debuggable API and an infuriating one.
A failure two levels down produces loc: ("author", "email"). Joined with dots, author.email. Three levels down with a list in the middle produces ("modules", 0, "lessons", 1, "minutes") — the second lesson of the first module.
Compare that with what a hand-rolled validator typically manages: "invalid module". For a payload of any size, the difference is between a caller who can fix the problem in ten seconds and one who has to bisect their own request.
There is a related failure worth recognising. If the value for a nested field is not a mapping at all — a string, say — the error is on the field itself, with loc: ("author",) and the type model_attributes_type. That means "I could not even begin to read this as an Author", which is a different problem from a field inside it being wrong, and the shape of the error tells you which you have.
Every rule still applies, at every level
A nested model is not a lesser thing. Its constraints run, its validators run, its defaults are filled, its config applies.
class Author(BaseModel):
name: str = Field(min_length=2)
email: str = Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")
Use that as a field type and every parent gets those rules for free. This is the compounding benefit of composition: a well-specified small model is reusable, and each place it is reused inherits the entire specification rather than a copy of it that can drift.
It is also the argument for making small models. Address, Money, DateRange, Contact — anything that appears in more than one place and has rules of its own is a model waiting to be extracted. The alternative is the same four fields and the same three constraints written out in five parent models, four of which get updated when the rule changes.
Shaping the output
model_dump takes include and exclude, and both understand structure. A set names top-level fields; a dict reaches inside. The two spellings do not mix in one literal, so once any entry needs to reach inside, every entry becomes a key:
That drops one top-level field and one field of the nested model, keeping everything else. For lists of models there is a special key, "__all__", which applies the same selection to every item.
This is genuinely useful for the common case of one internal representation and several external views. It is also easy to overuse. Once the exclusion dict is more than a couple of entries, a separate output model is clearer: it is checked, it appears correctly in the schema, and nobody has to trace an exclusion expression to work out what an endpoint actually returns.
The rule of thumb: exclude for removing one obviously-internal field, a separate model for anything structural.
Recursive models
A model can refer to itself, which is how you describe trees — comment threads, category hierarchies, nested menus:
class Node(BaseModel):
name: str
children: List["Node"] = []
The quotes are needed because the class does not exist yet at the point the annotation is written. In modern Python with from __future__ import annotations, or in files where all annotations are strings, you can drop them.
Recursive models validate to any depth and dump to nested dicts as you would expect. Two cautions. Depth is bounded by the recursion limit, so genuinely deep or maliciously nested input can raise, which is worth thinking about if the data is untrusted. And a cycle — a node that contains itself — will not terminate on dump; Pydantic detects some cases and raises, but the honest fix is not to build cyclic data in a model designed as a tree.
What good nesting looks like
Extract a model when a group of fields travels together and has rules of its own. Address is a model; city: str sitting beside postcode: str in four different parents is four copies of a decision.
Keep the tree shallow where you can. Three levels is normal; six usually means the shape is describing your storage rather than your domain, and a flatter representation with references would read better.
Name inner models for what they are, not for where they sit. Author is reusable; ModuleAuthor is a name that stops the model being used anywhere else even when it would fit perfectly.
Reusing a nested model across parents
The strongest argument for extracting a model is that the rules travel with it.
Use Address in Customer, Invoice and Warehouse and all three enforce the postcode rule. Change the rule and all three change. The alternative — three copies of three fields and one regular expression — is three chances for the copies to diverge, and they will.
The signal that a model wants extracting is fields that travel together. If city and postcode never appear apart, they are one concept with two attributes, and naming that concept makes the parent shorter and the rule reusable.
Validating against objects, not just dicts
Nested models normally arrive as dicts, because that is what JSON decodes to. Sometimes the source is objects instead — ORM rows, another library's classes — and by default a model will not read attributes off an arbitrary object.
from_attributes changes that:
class ModuleOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
title: str
author: AuthorOut
Now ModuleOut.model_validate(orm_row) reads row.title and row.author, and validates the nested object the same way. This is how a model turns a database row into a response, and it recurses, so a row with a related object becomes a nested model without any manual walking.
In Pydantic v1 this setting was called orm_mode, which is the name most existing tutorials use.
Performance, and what nesting costs
A nested model is a validation of its own. A parent with three nested models does four validations, and a list of fifty nested models does fifty-one.
That is the honest cost, and for a request handler it is irrelevant — the whole tree still validates in microseconds. It becomes worth thinking about for bulk work, and the advice is the same as everywhere else in this library: validate the tree once at the boundary, then pass the resulting objects around without re-validating.
The specific mistake to avoid is re-validating on the way out. Building a response by constructing an output model from an already-validated input model re-runs every rule in the tree for no benefit. If the shapes are the same, model_dump() and a direct construction is cheaper; if they differ, the second validation is doing real work and is fine.
A worked shape
Most real payloads are two or three levels, and the pattern is consistent:
class Author(BaseModel):
name: str
email: str
class Lesson(BaseModel):
name: str
minutes: int = Field(gt=0)
class Module(BaseModel):
title: str
author: Author
lessons: List[Lesson] = Field(min_length=1)
Read it top to bottom and you have the entire contract: a module has a title, exactly one author with a name and an email, and at least one lesson, each with a positive duration. No function needs to be opened to learn any of that, and none of it can drift, because it is the code that runs.
That is what nesting buys. Not the convenience of dotted access, though that is pleasant, but a specification that is executable.
The habit to take away
Every time you find a group of fields repeated across two models, or a comment explaining what shape a dictionary is meant to have, you have found a nested model waiting to be named.
The cost is three lines. The return is a rule that lives in one place, an error path that says exactly where a failure was, dotted access instead of chained lookups, and a schema that describes the real structure rather than a flat approximation of it.
Composition is the least clever feature in this library and one of the most valuable, precisely because it needs no special syntax. A model is a type. Types go in annotations. Everything else follows.
A note on how deep to go
Depth in a model tends to mirror depth in whatever produced the data, and that is not always the shape you want to work with.
A payload that nests six levels because a database has six joined tables is describing storage, not domain. Flattening it — or replacing an inner object with a reference and fetching separately — usually produces a model that reads better and an API that is easier to consume.
Three levels is comfortable. Four is worth a second look. Beyond that, the question is usually not "how do I model this" but "should the caller be receiving all of this at once".
Next
Nesting one model inside another is half the picture. The other half is collections: lists of models, dicts keyed by something meaningful, and what happens to the error path when the failure is in the seventeenth item.
Summary
A model used as a field type is validated with its full rules, dicts become objects recursively, and errors carry the whole path to the failing value.
Extract a model whenever fields travel together or a group of them is repeated. Use default_factory for a nested default. Reach for include/exclude to drop a field or two, and for a separate output model when the difference is structural.
Check yourself
0 of 4
Answer without scrolling back up.
What is the `loc` for a bad `email` inside a nested `author` field?
`loc` is the full path to the failing value. That is what makes a deeply nested payload debuggable rather than merely rejected.
You pass `author="Ada"` where a nested `Author` model is expected. Where does the error point?
A string cannot be read as an Author at all, so the failure is on the field rather than inside it - a different problem from one of Author's own fields being wrong.
Why use `Field(default_factory=Author)` rather than `= Author()` for a nested default?
The expression runs when the class body runs. A factory is called per instance, which is what a per-parent default needs.
How do you exclude one field of a nested model from `model_dump`?
`include` and `exclude` accept nested dicts to reach inside. For anything more structural than a field or two, a separate output model reads better and appears correctly in the schema.
Cheat sheet
Nested Models
That is it. Everything else follows from that one fact, which is worth stating plainly because people often expect a nested schema to need declaring somehow. It does not. If you can write the inner model on its own, you can use it as a field type.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.