← Back to FastAPI
Lesson 2.4 · Pydantic & Validation

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.

Intermediate30 min

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.

workflowAn order, level by levelstep 1 / 2

1 - Each level is checked

The address needs a 6-digit PIN; each line needs qty > 0. Errors point to the exact place.

pin "5000"
['ship_to', 'pin']
qty 0 in line 2
['lines', 1, 'qty']
lines: []
at least 1 item
good order total
48.0

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.

Small dictionary
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__.

Example 1 - nested.py
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__}
A good order (the JSON we sent)
{ "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.

Output - good and broken orders
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 integer

Why 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.

Output - the same mistake without a discriminator
2 errors without discriminator: ('payment', 'CardPayment', 'card_last4') missing ('payment', 'UpiPayment', 'method') literal_error

Trees: 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.

A recursive model
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 model

Checked with its own rules.

ship_to: Address
List of models

At least one item.

lines: list[OrderLine] = Field(min_length=1)
Dict

Typed keys and values.

extras: dict[str, int] = {}
One of several

Picked by a tag.

Annotated[CardPayment | UpiPayment, Field(discriminator="method")]
The tag field

Exactly one value per model.

method: Literal["card"]
A tree

Type in quotes.

children: list["Category"] = []

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Third payment

“Add CashPayment with method: Literal["cod"] and a change_for amount. Send it.”

Find the box

“Break the price of the third line. What is the loc?”

In the docs

“Open /docs and look at the Order schema. How is the payment union shown?”

Deep tree

“Send a tree four levels deep and count it.”

What usually goes wrong

Unions without a discriminator

Two errors for one mistake, and the client must guess. Add a tag field and Field(discriminator=...).

Counting list positions from 1

loc ["lines", 1, "qty"] is the SECOND line - positions start at 0.

Allowing empty lists

An order with no lines passed until we added Field(min_length=1).

A recursive type without quotes

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

What does loc ["ship_to", "pin"] mean?
The pin field inside the ship_to address.
How do you stop an order with no lines?
lines: list[OrderLine] = Field(min_length=1).
What does the discriminator do?
Reads the tag field (method) and checks only the matching model.
Why is "Category" written in quotes inside Category?
The class is not finished yet when that line is read.

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. 1.

    Which line had the problem in ["lines", 1, "qty"]?

  2. 2.

    What did method "cash" return?

  3. 3.

    How many errors did the same mistake give without a discriminator?

  4. 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...