Composing models: nesting, lists of models, dicts of models, unions and discriminated unions, recursive models, and the depth limits worth setting.
A model is a type, so a model can be a field.
class Address(BaseModel): line1: str city: str postcode: str
class Customer(BaseModel): name: str address: AddressCustomer.model_validate({ "name": "Ada", "address": {"line1": "1 High St", "city": "Cambridge", "postcode": "CB1 1AA"},})The nested dict is validated as an Address, and customer.address is a real
Address instance with its own validators applied.
Collections of models
Section titled “Collections of models”class Order(BaseModel): lines: list[OrderLine] metadata: dict[str, Tag] coordinates: tuple[Point, Point]Each item is validated. Errors carry the index, so a bad third line reports
["body", "lines", 2, "quantity"] rather than “something in lines is wrong”,
which is the difference between a client fixing it and a client guessing.
Bound the length of anything a client controls:
lines: list[OrderLine] = Field(min_length=1, max_length=500)Without an upper bound, a request can contain a hundred thousand lines, and your handler will loyally validate and insert every one.
Optional nesting
Section titled “Optional nesting”class Customer(BaseModel): address: Address | None = NoneBoth parts are needed: | None allows null, = None makes it optional. See
Types.
Unions
Section titled “Unions”class Payment(BaseModel): method: Card | BankTransfer | WalletPydantic tries each member in smart mode: an exact type match wins, otherwise it tries each in order and keeps the first success.
The problem shows up in the errors. When none matches, you get the failures for every member (three sets of field errors for one bad object) and the client has to work out which one you meant.
Discriminated unions
Section titled “Discriminated unions”from typing import Literalfrom pydantic import Field
class Card(BaseModel): kind: Literal["card"] number: str expiry: str
class BankTransfer(BaseModel): kind: Literal["bank"] iban: str
class Payment(BaseModel): method: Card | BankTransfer = Field(discriminator="kind")Now one field decides which model applies. Three things get better:
- Validation is one attempt, not n: faster, and O(1) in the number of members.
- Errors are the right ones. A
kind: "card"with a bad expiry reports the expiry, not every field of every variant. - The OpenAPI schema carries a proper
discriminator, so generated clients produce a real tagged union instead of an untypedoneOf.
An unknown tag gives one clear error naming the allowed values.
Use this for anything polymorphic that crosses the wire: payment methods, event payloads, notification channels, block types in a document.
Recursive models
Section titled “Recursive models”class Category(BaseModel): name: str children: list["Category"] = []The string annotation is what lets a class refer to itself. In modern Python this resolves automatically; if it does not, rebuild explicitly:
Category.model_rebuild()Same for two models that reference each other.
Building from ORM objects
Section titled “Building from ORM objects”class PostOut(BaseModel): model_config = ConfigDict(from_attributes=True)
id: int title: str author: AuthorOutpost = await Post.get(id=1).prefetch_related("author")PostOut.model_validate(post)from_attributes=True (v1’s orm_mode) lets a model read attributes instead
of dict keys, so an ORM instance can be validated directly.
Flattening
Section titled “Flattening”When the wire shape is flat and your model is not, reshape in a
before model validator:
class Customer(BaseModel): name: str city: str
@model_validator(mode="before") @classmethod def flatten(cls, data): if isinstance(data, dict) and isinstance(data.get("address"), dict): data = {**data, "city": data["address"].get("city")} return dataBetter still, keep the model matching the wire and do the flattening in your own code. A model whose shape does not match its JSON is a model every reader has to decode twice.
Schema names
Section titled “Schema names”Nested models appear in your OpenAPI document under components/schemas, keyed
by class name. Two classes with the same name in different modules collide, and
the second wins.
Give them distinct names (PostOut, PostSummary, PostAdminOut) rather than
reusing Post across modules. See OpenAPI.
When not to nest
Section titled “When not to nest”Nesting is right when the inner object is a real thing with its own identity and rules. It is wrong as a way of grouping fields for tidiness. Every level is another dict a client has to construct and another level of error paths to read.
For a response, prefer a flat model close to what the consumer actually wants. For a request, prefer whatever shape the client naturally sends.