FastAPI/
Lesson

Once you've mastered basic models, validators, and serializationWhat is serialization?Converting data from a program's internal format into a string or byte sequence that can be stored or sent over a network., you'll encounter Pydantic patterns that handle real-world complexity. APIs rarely deal with flat, simple objects. They deal with polymorphic data (different notification types), deeply nested structures (order with items with variants), and computed properties (full name from first + last). This lesson covers the patterns you'll see in production FastAPI code, and how to evaluate them when AI generates them.

Nested models

Pydantic models can contain other models. This is how you validate complex, nested JSONWhat is json?A text format for exchanging data between systems. It uses key-value pairs and arrays, and every programming language can read and write it. structures:

from pydantic import BaseModel, Field

class Address(BaseModel):
    street: str = Field(min_length=1)
    city: str = Field(min_length=1)
    zip_code: str = Field(pattern=r"^\d{5}$")
    country: str = Field(default="US", max_length=2)

class OrderItem(BaseModel):
    product_id: int = Field(gt=0)
    quantity: int = Field(ge=1, le=100)
    unit_price: float = Field(gt=0)

class OrderCreate(BaseModel):
    customer_name: str = Field(min_length=1, max_length=100)
    shipping_address: Address
    items: list[OrderItem] = Field(min_length=1)
    notes: str = ""
quot;) country: str = Field(default="US", max_length=2) class OrderItem(BaseModel): product_id: int = Field(gt=0) quantity: int = Field(ge=1, le=100) unit_price: float = Field(gt=0) class OrderCreate(BaseModel): customer_name: str = Field(min_length=1, max_length=100) shipping_address: Address items: list[OrderItem] = Field(min_length=1) notes: str = """ data-fullscreen-lang="python" class="absolute top-2 right-8 p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-gray-200 transition-colors opacity-0 group-hover/code:opacity-100 z-10" title="Fullscreen">
from pydantic import BaseModel, Field

class Address(BaseModel):
    street: str = Field(min_length=1)
    city: str = Field(min_length=1)
    zip_code: str = Field(pattern=r"^\d{5}$")
    country: str = Field(default="US", max_length=2)

class OrderItem(BaseModel):
    product_id: int = Field(gt=0)
    quantity: int = Field(ge=1, le=100)
    unit_price: float = Field(gt=0)

class OrderCreate(BaseModel):
    customer_name: str = Field(min_length=1, max_length=100)
    shipping_address: Address
    items: list[OrderItem] = Field(min_length=1)
    notes: str = ""
quot;) country: str = Field(default="US", max_length=2) class OrderItem(BaseModel): product_id: int = Field(gt=0) quantity: int = Field(ge=1, le=100) unit_price: float = Field(gt=0) class OrderCreate(BaseModel): customer_name: str = Field(min_length=1, max_length=100) shipping_address: Address items: list[OrderItem] = Field(min_length=1) notes: str = """ class="absolute top-2 right-2 p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-gray-200 transition-colors opacity-0 group-hover/code:opacity-100 z-10" title="Copy">
from pydantic import BaseModel, Field

class Address(BaseModel):
    street: str = Field(min_length=1)
    city: str = Field(min_length=1)
    zip_code: str = Field(pattern=r"^\d{5}$")
    country: str = Field(default="US", max_length=2)

class OrderItem(BaseModel):
    product_id: int = Field(gt=0)
    quantity: int = Field(ge=1, le=100)
    unit_price: float = Field(gt=0)

class OrderCreate(BaseModel):
    customer_name: str = Field(min_length=1, max_length=100)
    shipping_address: Address
    items: list[OrderItem] = Field(min_length=1)
    notes: str = ""

When FastAPI receives a JSON request, it validates recursively. If the zip code in the nested Address is invalid, Pydantic reports the exact path: shipping_address.zip_code.

json
{
    "customer_name": "Alice",
    "shipping_address": {
        "street": "123 Main St",
        "city": "Springfield",
        "zip_code": "12345"
    },
    "items": [
        {"product_id": 1, "quantity": 2, "unit_price": 9.99},
        {"product_id": 5, "quantity": 1, "unit_price": 24.99}
    ]
}

Error messages include the full path to the problem:

# If zip_code is "ABC":
# loc: ('shipping_address', 'zip_code')
# msg: "String should match pattern '^\\d{5}#039;"

# If an item has quantity 0:
# loc: ('items', 1, 'quantity')
# msg: "Input should be greater than or equal to 1"" data-fullscreen-lang="python" class="absolute top-2 right-8 p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-gray-200 transition-colors opacity-0 group-hover/code:opacity-100 z-10" title="Fullscreen">
# If zip_code is "ABC":
# loc: ('shipping_address', 'zip_code')
# msg: "String should match pattern '^\\d{5}
AI pitfall
AI generates nested models with correct structure but rarely adds constraints to the inner models. It'll create Address with street: str but not min_length=1, so your API accepts orders shipped to an empty string. Always validate inner models as carefully as the outer one.
"
# If an item has quantity 0: # loc: ('items', 1, 'quantity') # msg: "Input should be greater than or equal to 1"
AI pitfall
AI generates nested models with correct structure but rarely adds constraints to the inner models. It'll create Address with street: str but not min_length=1, so your API accepts orders shipped to an empty string. Always validate inner models as carefully as the outer one.
" # If an item has quantity 0: # loc: ('items', 1, 'quantity') # msg: "Input should be greater than or equal to 1"
#039;" # If an item has quantity 0: # loc: ('items', 1, 'quantity') # msg: "Input should be greater than or equal to 1"" data-fullscreen-lang="python" class="absolute top-2 right-8 p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-gray-200 transition-colors opacity-0 group-hover/code:opacity-100 z-10" title="Fullscreen">
# If zip_code is "ABC":
# loc: ('shipping_address', 'zip_code')
# msg: "String should match pattern '^\\d{5}#039;"

# If an item has quantity 0:
# loc: ('items', 1, 'quantity')
# msg: "Input should be greater than or equal to 1"" class="absolute top-2 right-2 p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-gray-200 transition-colors opacity-0 group-hover/code:opacity-100 z-10" title="Copy">
# If zip_code is "ABC":
# loc: ('shipping_address', 'zip_code')
# msg: "String should match pattern '^\\d{5}
AI pitfall
AI generates nested models with correct structure but rarely adds constraints to the inner models. It'll create Address with street: str but not min_length=1, so your API accepts orders shipped to an empty string. Always validate inner models as carefully as the outer one.
"
# If an item has quantity 0: # loc: ('items', 1, 'quantity') # msg: "Input should be greater than or equal to 1"
AI pitfall
AI generates nested models with correct structure but rarely adds constraints to the inner models. It'll create Address with street: str but not min_length=1, so your API accepts orders shipped to an empty string. Always validate inner models as carefully as the outer one.
" # If an item has quantity 0: # loc: ('items', 1, 'quantity') # msg: "Input should be greater than or equal to 1"
#039;" # If an item has quantity 0: # loc: ('items', 1, 'quantity') # msg: "Input should be greater than or equal to 1"" class="absolute top-2 right-2 p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-gray-200 transition-colors opacity-0 group-hover/code:opacity-100 z-10" title="Copy">
# If zip_code is "ABC":
# loc: ('shipping_address', 'zip_code')
# msg: "String should match pattern '^\\d{5}
AI pitfall
AI generates nested models with correct structure but rarely adds constraints to the inner models. It'll create Address with street: str but not min_length=1, so your API accepts orders shipped to an empty string. Always validate inner models as carefully as the outer one.
"
# If an item has quantity 0: # loc: ('items', 1, 'quantity') # msg: "Input should be greater than or equal to 1"
AI pitfall
AI generates nested models with correct structure but rarely adds constraints to the inner models. It'll create Address with street: str but not min_length=1, so your API accepts orders shipped to an empty string. Always validate inner models as carefully as the outer one.
02

Model inheritance

When multiple models share common fields, inheritance keeps your code DRY:

from pydantic import BaseModel, Field

# Base model with shared fields
class UserBase(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    email: str

# Input model - adds password
class UserCreate(UserBase):
    password: str = Field(min_length=8)

# Update model - all fields optional
class UserUpdate(BaseModel):
    name: str | None = None
    email: str | None = None

# Output model - adds server-generated fields
class UserOut(UserBase):
    id: int
    created_at: str
    # Note: no password field - it's not inherited from UserBase

# DB model - adds internal fields
class UserDB(UserBase):
    id: int
    password_hash: str
    is_admin: bool = False
    created_at: str

This pattern creates a clear hierarchy:

ModelFieldsPurpose
UserBasename, emailShared definition
UserCreatename, email, passwordClient input for registration
UserUpdatename?, email?Partial update (PATCH)
UserOutid, name, email, created_atAPI response
UserDBAll fieldsInternal/database representation
Good to know
UserUpdate intentionally does NOT inherit from UserBase. If it did, name and email would be required (since they have no default in UserBase). Update models need all fields optional, so they use a separate definition.
03

Discriminated unions

Discriminated unions solve a common APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. design problem: one endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. that accepts different shapes of data based on a type field. Think notification settings (email vs SMS vs push), payment methods (card vs bank vs wallet), or event types.

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

class EmailNotification(BaseModel):
    type: Literal["email"]
    recipient: str
    subject: str
    body: str

class SMSNotification(BaseModel):
    type: Literal["sms"]
    phone_number: str = Field(pattern=r"^\+\d{10,15}$")
    message: str = Field(max_length=160)

class PushNotification(BaseModel):
    type: Literal["push"]
    device_token: str
    title: str = Field(max_length=50)
    body: str = Field(max_length=200)

# Discriminated union - Pydantic checks "type" to pick the right model
Notification = Annotated[
    Union[EmailNotification, SMSNotification, PushNotification],
    Field(discriminator="type")
]
quot;) message: str = Field(max_length=160) class PushNotification(BaseModel): type: Literal["push"] device_token: str title: str = Field(max_length=50) body: str = Field(max_length=200) # Discriminated union - Pydantic checks "type" to pick the right model Notification = Annotated[ Union[EmailNotification, SMSNotification, PushNotification], Field(discriminator="type") ]" data-fullscreen-lang="python" class="absolute top-2 right-8 p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-gray-200 transition-colors opacity-0 group-hover/code:opacity-100 z-10" title="Fullscreen">
from pydantic import BaseModel, Field
from typing import Literal, Union, Annotated

class EmailNotification(BaseModel):
    type: Literal["email"]
    recipient: str
    subject: str
    body: str

class SMSNotification(BaseModel):
    type: Literal["sms"]
    phone_number: str = Field(pattern=r"^\+\d{10,15}$")
    message: str = Field(max_length=160)

class PushNotification(BaseModel):
    type: Literal["push"]
    device_token: str
    title: str = Field(max_length=50)
    body: str = Field(max_length=200)

# Discriminated union - Pydantic checks "type" to pick the right model
Notification = Annotated[
    Union[EmailNotification, SMSNotification, PushNotification],
    Field(discriminator="type")
]
quot;) message: str = Field(max_length=160) class PushNotification(BaseModel): type: Literal["push"] device_token: str title: str = Field(max_length=50) body: str = Field(max_length=200) # Discriminated union - Pydantic checks "type" to pick the right model Notification = Annotated[ Union[EmailNotification, SMSNotification, PushNotification], Field(discriminator="type") ]" class="absolute top-2 right-2 p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-gray-200 transition-colors opacity-0 group-hover/code:opacity-100 z-10" title="Copy">
from pydantic import BaseModel, Field
from typing import Literal, Union, Annotated

class EmailNotification(BaseModel):
    type: Literal["email"]
    recipient: str
    subject: str
    body: str

class SMSNotification(BaseModel):
    type: Literal["sms"]
    phone_number: str = Field(pattern=r"^\+\d{10,15}$")
    message: str = Field(max_length=160)

class PushNotification(BaseModel):
    type: Literal["push"]
    device_token: str
    title: str = Field(max_length=50)
    body: str = Field(max_length=200)

# Discriminated union - Pydantic checks "type" to pick the right model
Notification = Annotated[
    Union[EmailNotification, SMSNotification, PushNotification],
    Field(discriminator="type")
]

How it works: when Pydantic receives {"type": "sms", "phone_number": "+1234567890", "message": "Hi"}, it sees type: "sms", picks SMSNotification, and validates against that model's fields. If type: "email", it validates against EmailNotification instead.

Using the discriminated union in FastAPI:

@app.post("/notifications")
async def send_notification(notification: Notification):
    match notification.type:
        case "email":
            # notification is EmailNotification - has subject, body
            send_email(notification.recipient, notification.subject, notification.body)
        case "sms":
            # notification is SMSNotification - has phone_number, message
            send_sms(notification.phone_number, notification.message)
        case "push":
            # notification is PushNotification - has device_token, title
            send_push(notification.device_token, notification.title, notification.body)

Without a discriminator, Pydantic tries each model in order and uses the first one that validates. This is slow and error-prone, a valid EmailNotification might accidentally validate as an SMSNotification if the fields overlap. The discriminator field makes it explicit and fast.

04

Computed fields

Sometimes you want a field in the output that doesn't exist in the input, it's calculated from other fields. Pydantic v2's @computed_field handles this:

from pydantic import BaseModel, Field, computed_field

class CartItem(BaseModel):
    name: str
    quantity: int = Field(ge=1)
    unit_price: float = Field(gt=0)

    @computed_field
    @property
    def total_price(self) -> float:
        return round(self.quantity * self.unit_price, 2)

class Invoice(BaseModel):
    customer: str
    items: list[CartItem]

    @computed_field
    @property
    def subtotal(self) -> float:
        return round(sum(item.total_price for item in self.items), 2)

    @computed_field
    @property
    def tax(self) -> float:
        return round(self.subtotal * 0.2, 2)

    @computed_field
    @property
    def total(self) -> float:
        return round(self.subtotal + self.tax, 2)

Computed fields are:

  • Read-only: they cannot be set by the client
  • Included in serializationWhat is serialization?Converting data from a program's internal format into a string or byte sequence that can be stored or sent over a network.: they appear in .model_dump() and JSONWhat is json?A text format for exchanging data between systems. It uses key-value pairs and arrays, and every programming language can read and write it. responses
  • Included in JSON SchemaWhat is schema?A formal definition of the structure your data must follow - which fields exist, what types they have, and which are required.: they show up in APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. docs
  • Calculated on access: they recalculate each time they're read

item = CartItem(name="Widget", quantity=3, unit_price=9.99)
item.model_dump()
# {'name': 'Widget', 'quantity': 3, 'unit_price': 9.99, 'total_price': 29.97}
AI pitfall
AI sometimes computes derived values inside the endpoint function and adds them to the response dict manually, rather than using @computed_field. This works but scatters business logic across endpoint functions. Computed fields keep the logic in the model where it belongs.
05

Model configuration

model_config (using ConfigDict) controls model-wide behavior:

from pydantic import BaseModel, ConfigDict

class StrictUser(BaseModel):
    model_config = ConfigDict(
        strict=True,           # No type coercion
        frozen=True,           # Immutable after creation
        extra="forbid",        # Reject unknown fields
        str_strip_whitespace=True,  # Auto-strip whitespace from strings
    )
    name: str
    email: str

The most useful config options:

OptionDefaultWhat it does
strictFalseDisable type coercion ("42" stays a string, not converted to int)
frozenFalseMake instances immutable after creation
extra"ignore"What to do with unknown fields: "ignore", "allow", or "forbid"
str_strip_whitespaceFalseStrip leading/trailing whitespace from all string fields
populate_by_nameFalseAllow using both Python field names and aliases
use_enum_valuesFalseStore enum values instead of enum members

The extra="forbid" option deserves special attention. By default, if a client sends {"name": "Alice", "is_admin": true} and is_admin isn't in your model, Pydantic silently ignores it. With extra="forbid", that request returns a validation error. This is a security-relevant default, it prevents clients from sending fields you didn't expect.

06

Reading complex schemas AI generates

When you ask AI to build a real APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses., it often generates deeply nested Pydantic schemas. Here's how to evaluate them:

Signs the schemaWhat is schema?A formal definition of the structure your data must follow - which fields exist, what types they have, and which are required. is well-designed:

  • Each model represents a clear domain concept (User, Order, Address)
  • Nesting matches the actual data structure
  • Discriminated unions for genuinely polymorphic data
  • Separate input/output models

Signs the schema is over-engineered:

  • More than 3-4 levels of nesting
  • Abstract base classes with only one subclass
  • Discriminated unions with 2 types that share 90% of their fields
  • Generic models (Model[T]) when there's only one T
  • Separate models for Create and Update that are identical

Good to know
The right level of complexity depends on the API. A payments API genuinely needs discriminated unions (card vs bank vs wallet have different required fields). A blog API probably doesn't. When AI generates a complex schema, ask: does the problem actually require this, or is the AI showing off patterns from its training data?