Cheatsheet for composing model shapes.

Discriminated union

from typing import Literal, Annotated
from pydantic import Field, BaseModel

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. Clear errors. Fast.

Callable Discriminator

from pydantic import Discriminator, Tag

def get_kind(v):
    if isinstance(v, dict):
        return v.get("kind")
    return getattr(v, "kind", None)

Animal = Annotated[
    Annotated[Cat, Tag("cat")] | Annotated[Dog, Tag("dog")],
    Discriminator(get_kind),
]

When discriminator isn’t a simple Literal.

Generic models

from typing import TypeVar, Generic

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

page = Page[User](items=[User(...)], has_more=False)

Generic with constraints

T = TypeVar("T", bound=BaseModel)

class ApiResponse(BaseModel, Generic[T]):
    data: T
    status: str

Generic with multiple 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)

Recursive models

class Category(BaseModel):
    id: int
    name: str
    children: list["Category"] = Field(default_factory=list)

Category.model_rebuild()

model_rebuild() resolves forward refs.

RootModel

For models with a single root value (e.g., a list at the root):

from pydantic import RootModel

class Names(RootModel[list[str]]):
    pass

n = Names(["Alice", "Bob"])
n.root                        # ["Alice", "Bob"]
n.model_dump()                # ["Alice", "Bob"]  (no wrapping)

For when JSON is ["a", "b"] not {"items": [...]}.

Lists of unions

class Notification(BaseModel):
    items: list[Email | SMS | Push]

Best with discriminator. Without: pydantic tries each in order — can be ambiguous / slow.

Optional / nullable nested

class User(BaseModel):
    profile: Profile | None = None

None allowed; default to None.

Polymorphism via discriminator

class ItemBase(BaseModel):
    id: int
    name: str

class Book(ItemBase):
    kind: Literal["book"]
    isbn: str

class CD(ItemBase):
    kind: Literal["cd"]
    artist: str

Item = Annotated[Book | CD, Field(discriminator="kind")]

In a list:

class Cart(BaseModel):
    items: list[Item]

Generic discriminated union

class Cat(BaseModel, Generic[T]):
    kind: Literal["cat"]
    meta: T

class Dog(BaseModel, Generic[T]):
    kind: Literal["dog"]
    meta: T

AnimalWithMeta = Annotated[Cat[T] | Dog[T], Field(discriminator="kind")]

Advanced. Use sparingly.

Self-referencing alias

class TreeNode(BaseModel):
    value: int
    left: "TreeNode | None" = None
    right: "TreeNode | None" = None

TreeNode.model_rebuild()

Common mistakes

  • Untagged unions in hot paths — Pydantic tries each member; slow + ambiguous.
  • Forgetting model_rebuild() for recursive — runtime error.
  • Mixing Generic[T] with default model_config — sometimes confusing inheritance.
  • Overlapping discriminator values across union members.

Read this next

If you want my discriminated-union API patterns, it’s at rajpoot.dev .


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 .