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 .