Models inside models: composition, dicts that become objects, and error paths that reach all the way down.
Overview
Composition is the whole mechanism
Real data has structure. A course has modules. A module has an author. An order has a customer and a list of items. Flat models cannot describe that, so Pydantic lets one model appear as the type of a field in another.
That is all nesting is. You do not write any special code to make it work. When the outer model validates, it hands each inner piece to the inner model, and the inner model checks it by its own rules. A plain dictionary goes in. A tree of typed objects comes out.
This article explains how composition works and why it needs so little from you. You will see how a dictionary becomes an object at every level of the tree. You will also see how errors report the exact path to the bad value, which matters when the data is three levels deep. The last part makes the point that every rule you set on a model still applies when that model is used inside another one. Constraints, defaults and validators all come along without extra effort.
This is the point where models start to look like the data you actually handle, so it is worth getting comfortable with it. Nothing new is needed beyond what you have already seen.
There's no special syntax for nesting. A model is a type. A field can be annotated with any type. A field can be a model:
class Author(BaseModel):
name: str
email: str
class Module(BaseModel):
title: str
author: Author
That is it. all else follows from that one fact. This Is worth stating plainly because people often expect a nested schema to need declaring somehow. It doesn't. If you can write the inner model on its own, you can use it as a field type.
When Module is checked, the value for author is handed to Author for check. If it's already an Author, it passes through. If it's a dict, it turns one. If it's 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
from pydantic import BaseModel
class Author(BaseModel):
name: str
email: str
class Module(BaseModel):
title: str
author: Author # <- another model
m = Module(title="Vectors",
author={"name": "Ada", "email": "ada@vizlearn.in"})
print(m)
print()
print("author is a :", type(m.author).__name__)
print("dotted acces:", m.author.name)
print("not a dict :", isinstance(m.author, dict))
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
from pydantic import BaseModel, ValidationError
class Author(BaseModel):
name: str
email: str
class Module(BaseModel):
title: str
author: Author
try:
Module(title="Vectors", author={"name": None, "email": 42})
except ValidationError as e:
for err in e.errors():
path = ".".join(str(p) for p in err["loc"])
print("%-16s %s" % (path, err["msg"]))
print()
# Compare with a failure at the top level:
try:
Module(title="Vectors", author="Ada")
except ValidationError as e:
err = e.errors()[0]
print("%-16s %s" % (".".join(str(p) for p in err["loc"]), err["msg"]))
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
from typing import List
from pydantic import BaseModel, ValidationError
class Lesson(BaseModel):
name: str
minutes: int
class Module(BaseModel):
title: str
lessons: List[Lesson]
class Track(BaseModel):
title: str
modules: List[Module]
data = {"title": "Maths", "modules": [
{"title": "Vectors", "lessons": [
{"name": "Direction", "minutes": 4},
{"name": "Magnitude", "minutes": "soon"}, # <- three levels down
]},
]}
try:
Track.model_validate(data)
except ValidationError as e:
err = e.errors()[0]
print("path :", err["loc"])
print("read :", ".".join(str(p) for p in err["loc"]))
print("said :", err["msg"])
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
from typing import Optional
from pydantic import BaseModel, Field
class Author(BaseModel):
name: str = "unknown"
email: Optional[str] = None
class Module(BaseModel):
title: str
author: Optional[Author] = None # may be absent entirely
reviewer: Author = Field(default_factory=Author) # always present
bare = Module(title="Norms")
print("no author :", bare.author)
print("but reviewer :", bare.reviewer)
print()
full = Module(title="Norms", author={"name": "Ada"})
print("given :", full.author)
print("inner default:", full.author.email)
Output
The rules from the defaults module apply without change. One of them has a exact trap here.
For a nested model that should always exist, use default_factory:
reviewer: Author = Field(default_factory=Author)
Not = Author(). That formula is checked once, when the class body runs. Each 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 Making the instance at class-meaning time is still the wrong shape. A factory says what you mean.
For a nested model that may truly be absent, Optional[Author] = None behaves just as it does for any other type, with the same gap between omitted and by name null.
Note that a nested model whose own fields all have defaults can be built from an empty dict. This At times surprises people: Module(title="x", author={}) succeeds if each Author field has a default. That is correct. It's 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
from typing import List
from pydantic import BaseModel
class Lesson(BaseModel):
name: str
minutes: int
class Module(BaseModel):
title: str
lessons: List[Lesson]
m = Module(title="Vectors", lessons=[
{"name": "Direction", "minutes": 4},
{"name": "Magnitude", "minutes": 6},
])
d = m.model_dump()
print("dict :", d)
print("inner :", type(d["lessons"][0]).__name__, "- plain dicts, all the way down")
print()
print("json :", m.model_dump_json())
print()
again = Module.model_validate(d)
print("round trip equal:", again == m)
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
from typing import List
from pydantic import BaseModel
class Author(BaseModel):
name: str
email: str
class Module(BaseModel):
title: str
author: Author
internal_notes: str
m = Module(title="Vectors",
author={"name": "Ada", "email": "ada@vizlearn.in"},
internal_notes="needs a diagram")
print("everything :", m.model_dump())
print()
print("public :", m.model_dump(exclude={"internal_notes": True,
"author": {"email"}}))
print()
print("just a bit :", m.model_dump(include={"title": True,
"author": {"name"}}))
Output
Dicts in, objects out
The conversion from dict to object is the part that makes nesting handy rather than merely possible.
JSON has objects, and Python decodes them as dicts. Without nested models you'd receive {"author": {"name": "Ada"}} and reach into it with data["author"]["name"]. Two dict lookups, each of which can raise KeyError, neither of which your editor can help with.
With nesting you get m.author.name. It's checked, it's autocompleted. A typo is an AttributeError at the point of the typo rather than a KeyError one spot downstream.
The reverse works too: model_dump() recurses. 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. It's worth dwelling on because it's the gap between a debuggable API and an infuriating one.
A failure two levels down makes loc: ("author", "email"). Joined with dots, author.email. Three levels down with a list in the middle makes ("modules", 0, "lessons", 1, "minutes"). The second lesson of the first module.
Compare that with what a hand-rolled checker often manages: "invalid module". For a payload of any size, the gap is between a caller who can fix the problem in ten seconds and one who has to bisect their own request.
There's a related failure worth recognising. If the value for a nested field isn't 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 couldn't even begin to read this as an Author". This Is a other problem from a field inside it being wrong. The shape of the error tells you which you've.
Every rule still applies, at every level
A nested model isn't a lesser thing. Its constraints run, its checkers 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 each parent gets those rules for free. This is the compounding gain of composition. A well-given small model is reusable. Each place it's reused takes from the entire specification rather than a copy of it that can drift.
It's also the arg 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 option 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 don't mix in one literal. Once any entry needs to reach inside, each entry turns a key:
That drops one top-level field and one field of the nested model, keeping all else. For lists of models there's a special key, "__all__". This Applies the same pick to each item.
This is truly handy for the common case of one inner form and a few external views. It's also easy to overuse. Once the exclusion dict is more than a couple of entries, a split output model is clearer. It's checked, it appears right in the schema. No one has to trace an exclusion formula to work out what an endpoint really returns.
The rule of thumb: exclude for dropping one obviously-inner field, a split model for anything structural.
Recursive models
A model can refer to itself. This Is how you show trees. Comment threads, group trees, nested menus:
class Node(BaseModel):
name: str
children: List["Node"] = []
The quotes are needed because the class doesn't 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'd expect. Two cautions. Depth is bounded by the recursion limit. Truly deep or maliciously nested input can raise. This Is worth thinking about if the data is untrusted. And a cycle. A node that holds itself. Won't terminate on dump. Pydantic detects some cases and raises. But The honest fix isn't 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 other parents is four copies of a choice.
Keep the tree shallow where you can. Three levels is normal. Six often means the shape is showing your storage rather than your domain. A flatter form with links would read better.
Name inner models for what they're, 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 just right.
Reusing a nested model across parents
The strongest arg 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 option. Three copies of three fields and one regular formula. Is three chances for the copies to diverge, and they'll.
The signal that a model wants extracting is fields that travel together. If city and postcode never appear apart, they're one concept with two fields. Naming that concept makes the parent shorter and the rule reusable.
Validating against objects, not just dicts
Nested models most times arrive as dicts. That is what JSON decodes to. at times the source is objects instead. ORM rows, another library's classes. And by default a model won't read fields off an any 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. Validates the nested object the same way. This is how a model turns a DB row into a response, and it recurses. A row with a related object turns a nested model without any manual walking.
In Pydantic v1 this setting was called orm_mode. This Is the name most existing tutorials use.
Performance, and what nesting costs
A nested model is a check of its own. A parent with three nested models does four validations. A list of fifty nested models does fifty-one.
That is the honest cost, and for a request handler it's irrelevant. The whole tree still validates in microseconds. It turns worth thinking about for bulk work, and the advice is the same as all over else in this library. Validate the tree once at the boundary, then pass the resulting objects around without re-checking.
The exact mistake to avoid is re-checking on the way out. Building a response by making an output model from an already-checked input model re-runs each rule in the tree for no gain. If the shapes are the same, model_dump() and a direct build is cheaper. If they differ, the second check is doing real work and is fine.
A worked shape
Most real payloads are two or three levels, and the pattern is steady:
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've the entire contract. A module has a title, just one author with a name and an email. At least one lesson, each with a plus duration. No function needs to be opened to learn any of that. None of it can drift. It's the code that runs.
That is what nesting buys. Not the convenience of dotted access, though that is pleasant. A specification that is executable.
The habit to take away
each time you find a group of fields repeated across two models, or a comment showing what shape a dict is meant to have, you've 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 just where a failure was, dotted access instead of chained lookups. A schema that shows the real structure rather than a flat guess of it.
Composition is the least clever feature in this library and one of the most valuable, just because it needs no special syntax. A model is a type. Types go in annotations. all else follows.
A note on how deep to go
Depth in a model tends to mirror depth in what made the data. That isn't always the shape you want to work with.
A payload that nests six levels because a DB has six joined tables is showing storage, not domain. Flattening it. Or replacing an inner object with a link and fetching apart. Often makes 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 often not "how do I model this" but "should the caller be receiving all of this at once".
Where to go after Nested Models
Nesting one model inside another is half the picture. The other half is sets. Lists of models, dicts keyed by a thing real. What happens to the error path when the failure is in the seventeenth item.
Questions people ask about Nested Models
How do I put a model inside another model? Use the inner class as a field type, for example author: Author. Pydantic validates a nested dict into an Author instance.
Where does the error point when a nested value is wrong? The loc is the full path, such as ('author', 'email'), so you can see exactly which inner field failed.
Can a nested model be optional? Yes. Use author: Author | None = None. A missing key then gives None, while a present dict is validated.
Can a model contain itself? Yes, use a forward reference like children: list["Category"]. Pydantic resolves it once the class exists.
How do I dump only part of a nested model? Pass a nested exclude such as exclude={"author": {"email"}}. include accepts the same shape.
Do nested models get copied or shared? If you pass an existing model instance, Pydantic keeps the same object by default (revalidate_instances='never'). Passing a dict creates a new one.
Nested Models: summary
A model used as a field type is checked with its full rules, dicts become objects recursively. Errors carry the whole path to the failing value.
Extract a model when 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. For a split output model when the gap 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
Compose models inside models so nested JSON validates recursively, with error paths that name the exact field at every depth.
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.