Nested Models and Lists
Real data has structure: an order has an address, many order lines and one of several payment types. Build it from models inside models, lists and dicts, read errors that point deep inside ("lines.1.qty"), and let one field choose the right model with a discriminated union.
What you will be able to do
- Put models inside models, and lists and dicts of models
- Require at least one item in a list
- Read error locations that point inside nested data
- Accept one of several shapes with a discriminated union
- Build a tree with a model that contains itself
The idea, in plain English
So far our models were flat: a few fields of simple types. Real data is not flat. An order has a delivery address (street, city, PIN code), a list of order lines (product, quantity, price each), and a payment that is either a card or UPI - each with different fields.
Pydantic handles this naturally: a field can be another model, a list of models, or a dict. Every level is checked with its own rules, and errors point to the exact place - "the quantity of the second order line".
In this lesson we build an order model, send it good and broken orders, add a payment that can be one of two shapes, and finish with a category tree. FastAPI 0.143.0, Pydantic 2.14.0.
Worked example: An order with an address, order lines, extras and a UPI or card payment - plus a category tree that contains itself.
1 - Each level is checked
The address needs a 6-digit PIN; each line needs qty > 0. Errors point to the exact place.
Each part of the order is its own model. Real results from our tests.
Words you will see in this lesson
A few words about structured data.
Nested modelA model used as the type of a field in another model.UnionA type that can be one of several: A | B.DiscriminatorA field (like method) whose value says which model of a union to use.TagThe value of the discriminator: "card", "upi".Recursive modelA model that contains itself - for trees like categories.An everyday example: a delivery box
A delivery box has a label (the address - its own small form), a packing list (many lines, each with product and quantity), and a payment slip that is either "card" or "cash on delivery", each with different details. The warehouse checks each part with its own rules.
That is exactly a nested model: Order has an Address, a list of OrderLine, and a Payment that is one of two kinds.
Example 1 - an order with nested parts
Each part is a small model with its own rules. Order then uses them as types: ship_to: Address, lines: list[OrderLine] with Field(min_length=1) so an order cannot be empty, extras: dict[str, int] for named numbers like {"gift_wrap": 1}.
Payment is a discriminated union: CardPayment | UpiPayment, with Field(discriminator="method"). Each payment model has a method field typed Literal["card"] or Literal["upi"]. Pydantic reads method first and then checks only the matching model.
Inside the endpoint, everything is a real object: order.ship_to.city, line.qty, type(order.payment).__name__.
from typing import Annotated, Literal
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class Address(BaseModel):
street: str
city: str
pin: str = Field(pattern=r"^\d{6}$") # Indian PIN code: 6 digits
class OrderLine(BaseModel):
product: str
qty: int = Field(gt=0)
price: float = Field(gt=0)
class CardPayment(BaseModel):
method: Literal["card"]
card_last4: str = Field(pattern=r"^\d{4}$")
class UpiPayment(BaseModel):
method: Literal["upi"]
upi_id: str = Field(pattern=r"^[\w.-]+@[\w]+$")
Payment = Annotated[CardPayment | UpiPayment, Field(discriminator="method")] # "method" picks the model
class Order(BaseModel):
customer: str
ship_to: Address # a model inside a model
lines: list[OrderLine] = Field(min_length=1) # a list of models, at least one
gift_note: str | None = None
extras: dict[str, int] = {} # e.g. {"gift_wrap": 1}
payment: Payment
@app.post("/orders")
def create_order(order: Order):
total = sum(line.qty * line.price for line in order.lines)
return {"customer": order.customer, "city": order.ship_to.city, "lines": len(order.lines),
"total": total, "payment": order.payment.method,
"payment_model": type(order.payment).__name__}{
"customer": "Ravi",
"ship_to": {"street": "12 MG Road", "city": "Hyderabad", "pin": "500001"},
"lines": [
{"product": "Lamp", "qty": 2, "price": 19.5},
{"product": "Bulb", "qty": 4, "price": 2.25}
],
"payment": {"method": "upi", "upi_id": "ravi@okbank"}
}Errors that point deep inside
Read loc as a path into the JSON. ["ship_to", "pin"]: the pin inside ship_to. ["lines", 1, "qty"]: the qty of item number 1 in lines - counting from 0, so the SECOND line. ["payment", "card", "card_last4"]: inside the payment, which was recognised as a card, card_last4 is missing.
A frontend can use this path to put the error message next to the exact input box - even in a list of order lines.
good order -> 200 {"customer":"Ravi","city":"Hyderabad","lines":2,"total":48.0,"payment":"upi","payment_model":"UpiPayment"}
card payment -> 200 {"customer":"Ravi","city":"Hyderabad","lines":2,"total":48.0,"payment":"card","payment_model":"CardPayment"}
bad pin -> 422 ['ship_to', 'pin'] string_pattern_mismatch: String should match pattern '^\d{6}$'
bad 2nd line -> 422 ['lines', 1, 'qty'] greater_than: Input should be greater than 0
no lines -> 422 ['lines'] too_short: List should have at least 1 item after validation, not 0
missing city -> 422 ['ship_to', 'city'] missing: Field required
card without last4 -> 422 ['payment', 'card', 'card_last4'] missing: Field required
unknown method -> 422 ['payment'] union_tag_invalid: Input tag 'cash' found using 'method' does not match any of the expected tags: 'card', 'upi'
extras -> 422 ['extras', 'express'] int_parsing: Input should be a valid integer, unable to parse string as an integerWhy the discriminator matters
Without a discriminator, Pydantic tries every model of the union and reports why each one failed. We sent a card payment with a UPI field by mistake. Without the discriminator we got two errors - one for CardPayment (card_last4 missing) and one for UpiPayment (method should be "upi"). The client has to guess which one is meant.
With the discriminator, Pydantic reads method = "card", checks only CardPayment, and gives one clear error. An unknown method like "cash" gets its own clear error listing the allowed tags. Use a discriminator whenever a union of models has a field that tells them apart.
2 errors without discriminator:
('payment', 'CardPayment', 'card_last4') missing
('payment', 'UpiPayment', 'method') literal_errorTrees: a model that contains itself
Some data is a tree: categories inside categories, comments with replies. A model can contain a list of itself - write the type in quotes, "Category", because the class is not finished yet when Python reads that line. We sent All -> Books -> Python and Web, and All -> Toys: 5 categories, counted with a small recursive function.
class Category(BaseModel): # a model that contains itself: a tree
name: str
children: list["Category"] = []
@app.post("/categories")
def count(tree: Category):
def size(c): return 1 + sum(size(ch) for ch in c.children)
return {"root": tree.name, "total_categories": size(tree)}
# POST {"name": "All", "children": [{"name": "Books", "children": [{"name": "Python"}, {"name": "Web"}]}, {"name": "Toys"}]}
# -> 200 {"root":"All","total_categories":5}Nested data at a glance
Model in a modelChecked with its own rules.
ship_to: Address
List of modelsAt least one item.
lines: list[OrderLine] = Field(min_length=1)
DictTyped keys and values.
extras: dict[str, int] = {}One of severalPicked by a tag.
Annotated[CardPayment | UpiPayment, Field(discriminator="method")]
The tag fieldExactly one value per model.
method: Literal["card"]
A treeType in quotes.
children: list["Category"] = []
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Add CashPayment with method: Literal["cod"] and a change_for amount. Send it.”
“Break the price of the third line. What is the loc?”
“Open /docs and look at the Order schema. How is the payment union shown?”
“Send a tree four levels deep and count it.”
What usually goes wrong
Two errors for one mistake, and the client must guess. Add a tag field and Field(discriminator=...).
loc ["lines", 1, "qty"] is the SECOND line - positions start at 0.
An order with no lines passed until we added Field(min_length=1).
The class is not defined yet inside itself.
✗ children: list[Category] = []✓ children: list["Category"] = []Key points
- Fields can be models, lists of models and dicts - each level is checked with its own rules.
- Error loc is a path: ["lines", 1, "qty"] is the qty of the second line.
- Field(min_length=1) on a list forbids empty lists.
- A discriminated union uses a tag field (Literal) to pick one model, with one clear error.
- Without a discriminator, Pydantic reports errors for every model it tried.
- A model can contain a list of itself for trees: list["Category"].
Quick check before you move on
Interview questions
How does Pydantic validate nested data?
Recursively: each nested model, list item and dict value is validated against its own type and constraints, and errors carry a loc path (including list indexes) to the exact failing value.
What is a discriminated union and why use it?
A union of models selected by a tag field (Literal values) via Field(discriminator=...). Validation is faster and errors are precise, because only the matching model is validated; it also produces a clear OpenAPI schema.
How do you model tree structures with Pydantic?
With self-referencing models using a forward reference, like children: list["Category"]; Pydantic resolves it once the class is defined.
Quiz
- 1.
Which line had the problem in ["lines", 1, "qty"]?
- 2.
What did method "cash" return?
- 3.
How many errors did the same mistake give without a discriminator?
- 4.
What did "express": "yes" in extras give?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...