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 .