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:
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.
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 fieldsclassUserBase(BaseModel):
name:str= Field(min_length=1, max_length=100)
email:str# Input model - adds passwordclassUserCreate(UserBase):
password:str= Field(min_length=8)# Update model - all fields optionalclassUserUpdate(BaseModel):
name:str|None=None
email:str|None=None# Output model - adds server-generated fieldsclassUserOut(UserBase):id:int
created_at:str# Note: no password field - it's not inherited from UserBase# DB model - adds internal fieldsclassUserDB(UserBase):id:int
password_hash:str
is_admin:bool=False
created_at:str
This pattern creates a clear hierarchy:
Model
Fields
Purpose
UserBase
name, email
Shared definition
UserCreate
name, email, password
Client input for registration
UserUpdate
name?, email?
Partial update (PATCH)
UserOut
id, name, email, created_at
API response
UserDB
All fields
Internal/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. Ask AI for more design problem: one endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. Ask AI for more 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
classEmailNotification(BaseModel):type: Literal["email"]
recipient:str
subject:str
body:strclassSMSNotification(BaseModel):type: Literal["sms"]
phone_number:str= Field(pattern=r"^\+\d{10,15}$")
message:str= Field(max_length=160)classPushNotification(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
classEmailNotification(BaseModel):type: Literal["email"]
recipient:str
subject:str
body:strclassSMSNotification(BaseModel):type: Literal["sms"]
phone_number:str= Field(pattern=r"^\+\d{10,15}$")
message:str= Field(max_length=160)classPushNotification(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
classEmailNotification(BaseModel):type: Literal["email"]
recipient:str
subject:str
body:strclassSMSNotification(BaseModel):type: Literal["sms"]
phone_number:str= Field(pattern=r"^\+\d{10,15}$")
message:str= Field(max_length=160)classPushNotification(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")asyncdefsend_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
classCartItem(BaseModel):
name:str
quantity:int= Field(ge=1)
unit_price:float= Field(gt=0)@computed_field@propertydeftotal_price(self)->float:returnround(self.quantity * self.unit_price,2)classInvoice(BaseModel):
customer:str
items:list[CartItem]@computed_field@propertydefsubtotal(self)->float:returnround(sum(item.total_price for item in self.items),2)@computed_field@propertydeftax(self)->float:returnround(self.subtotal *0.2,2)@computed_field@propertydeftotal(self)->float:returnround(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. Ask AI for more: 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. Ask AI for more 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. Ask AI for more: 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. Ask AI for more docs
Calculated on access: they recalculate each time they're read
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.
from pydantic import BaseModel, ConfigDict
classStrictUser(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:
Option
Default
What it does
strict
False
Disable type coercion ("42" stays a string, not converted to int)
frozen
False
Make instances immutable after creation
extra
"ignore"
What to do with unknown fields: "ignore", "allow", or "forbid"
str_strip_whitespace
False
Strip leading/trailing whitespace from all string fields
populate_by_name
False
Allow using both Python field names and aliases
use_enum_values
False
Store 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. Ask AI for more, 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. Ask AI for more 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?