Chapter 5: model composition. Nested, generic, discriminated, recursive.
Nested models
class Address(BaseModel):
street: str
city: str
country: str
class User(BaseModel):
name: str
address: Address
backup_addresses: list[Address] = []
Validates deeply. Errors point to the failing path.
Generics
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: list[T]
cursor: str | None = None
has_more: bool
class User(BaseModel):
id: int
email: str
# Use
page = Page[User](items=[User(id=1, email="[email protected]")], has_more=False)
Reusable generic types.
Generic in FastAPI response_model
@app.get("/users", response_model=Page[User])
async def list_users(): ...
OpenAPI generates Page_User_ schema. Type-safe.
Discriminated unions
from typing import Literal, Annotated
from pydantic import Field
class Cat(BaseModel):
kind: Literal["cat"]
meows: int
class Dog(BaseModel):
kind: Literal["dog"]
barks: int
Animal = Annotated[Cat | Dog, Field(discriminator="kind")]
class Pet(BaseModel):
name: str
animal: Animal
Pydantic dispatches on kind. Clearer errors; faster than untagged unions.
Discriminator function
For non-literal discrimination:
from pydantic import Discriminator, Tag
def get_kind(v):
if isinstance(v, dict):
return v.get("kind")
return getattr(v, "kind", None)
Animal = Annotated[
Cat | Dog,
Discriminator(get_kind),
]
When the discriminator field isn’t a simple Literal.
Recursive models
class Comment(BaseModel):
id: int
text: str
replies: list["Comment"] = []
Comment.model_rebuild()
Forward reference; model_rebuild after class definition for self-references.
Trees
class Category(BaseModel):
id: int
name: str
children: list["Category"] = []
Category.model_rebuild()
cat = Category.model_validate({
"id": 1, "name": "Root",
"children": [
{"id": 2, "name": "Sub", "children": []},
],
})
Optional / nullable nested
class User(BaseModel):
profile: Profile | None = None
Nested object can be missing or null.
Lists of unions
class Notification(BaseModel):
items: list[Email | SMS | Push]
Best with discriminator. Without, parsing tries each in order; can be ambiguous.
Generic constraint
T = TypeVar("T", bound=BaseModel)
class ApiResponse(BaseModel, Generic[T]):
data: T
status: str
Constrains T to BaseModel subclasses.
Multiple type params
K = TypeVar("K")
V = TypeVar("V")
class Pair(BaseModel, Generic[K, V]):
key: K
value: V
p = Pair[str, int](key="x", value=42)
Nested model_dump
user.model_dump()
# {"name": "Alice", "address": {"street": "...", ...}}
user.model_dump(exclude={"address": {"country"}})
# {"name": "Alice", "address": {"street": "...", "city": "..."}}
Nested exclude/include uses dict-of-sets syntax.
Common mistakes
1. Untagged unions in hot paths
class M(BaseModel):
value: int | str | dict
Pydantic tries each; can be slow / ambiguous. Use discriminator if possible.
2. Forgetting model_rebuild
Recursive models without rebuild fail at validate-time.
3. Mutable nested defaults
class M(BaseModel):
addresses: list[Address] = [] # shared!
Field(default_factory=list).
4. Generic as dict
class Page(BaseModel):
items: list # untyped
Use generics for type information.
5. Discriminator with overlapping types
class A(BaseModel):
kind: Literal["x"]
foo: str
class B(BaseModel):
kind: Literal["x"] # same; ambiguous
Each member needs unique discriminator value.
What’s next
Chapter 6: Custom types and TypeAdapter.
Read this next
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .