Pydantic Deep Dive¶
Pydantic is the validation engine inside FastAPI. The better you know it, the cleaner your API code.
This chapter dives into the features you'll actually use in production: field constraints, validators, computed fields, nested models, and the new Pydantic v2 patterns.
Quick refresher¶
from pydantic import BaseModel
class Patient(BaseModel):
name: str
age: int
p = Patient(name="Alice", age=25)
print(p.name) # 'Alice'
print(p.model_dump()) # {'name': 'Alice', 'age': 25}
print(p.model_dump_json()) # '{"name":"Alice","age":25}'
Field() — adding rules¶
Field(default, **rules) lets you set constraints, descriptions, examples:
from pydantic import BaseModel, Field
from typing import Annotated
class Product(BaseModel):
name: Annotated[str, Field(..., min_length=2, max_length=100,
description="Product name", examples=["Pen"])]
price: Annotated[float, Field(..., gt=0, le=1_000_000)]
stock: Annotated[int, Field(0, ge=0)] # default 0, >=0
sku: Annotated[str, Field(..., pattern=r"^[A-Z]{3}-\d{4}$")]
# Valid
Product(name="Pen", price=10.5, sku="ABC-1234")
# Invalid: ValueError — gt=0 violated
# Product(name="Pen", price=-5, sku="ABC-1234")
Annotated[T, Field(...)] is the modern syntax. Older name: str = Field(...) also works.
Common Field constraints¶
| Constraint | Applies to | Meaning |
|---|---|---|
gt / ge / lt / le |
numbers | greater-than / >= / less-than / <= |
min_length / max_length |
str, list | length |
pattern |
str | regex match |
multiple_of |
numbers | must be a multiple |
examples |
any | show in Swagger UI |
description |
any | docs |
Validators — custom checks¶
When Field rules aren't enough, write a @field_validator:
from pydantic import BaseModel, field_validator
class User(BaseModel):
email: str
age: int
@field_validator("email")
@classmethod
def email_must_be_corporate(cls, v: str) -> str:
if not v.endswith("@example.com"):
raise ValueError("email must end with @example.com")
return v.lower() # normalize while we're here
@field_validator("age")
@classmethod
def reasonable_age(cls, v: int) -> int:
if v > 150:
raise ValueError("age too large")
return v
# Valid + lowercased
User(email="Alice@Example.com", age=25)
# Raises ValidationError
# User(email="alice@gmail.com", age=25)
Validators run after the type conversion. They can: - Reject the value (raise). - Transform the value (return a different value).
Cross-field validation — @model_validator¶
Some rules involve two or more fields:
from pydantic import BaseModel, model_validator
class DateRange(BaseModel):
start: int
end: int
@model_validator(mode="after")
def end_after_start(self):
if self.end < self.start:
raise ValueError("end must be >= start")
return self
DateRange(start=1, end=5) # ok
# DateRange(start=10, end=5) # ValidationError
Computed fields — derived data¶
Compute a value from other fields automatically:
from pydantic import BaseModel, computed_field
class Person(BaseModel):
height: float # meters
weight: float # kg
@computed_field
@property
def bmi(self) -> float:
return round(self.weight / (self.height ** 2), 2)
@computed_field
@property
def bmi_category(self) -> str:
if self.bmi < 18.5: return "Underweight"
if self.bmi < 25: return "Normal"
if self.bmi < 30: return "Overweight"
return "Obese"
p = Person(height=1.7, weight=70)
print(p.model_dump())
# {'height': 1.7, 'weight': 70, 'bmi': 24.22, 'bmi_category': 'Normal'}
bmi and bmi_category appear in model_dump() and in the API response — but they're not required in the input.
Useful built-in types¶
from pydantic import BaseModel, EmailStr, HttpUrl, SecretStr
from datetime import date, datetime
from uuid import UUID
class User(BaseModel):
id: UUID
email: EmailStr # validates email format
website: HttpUrl # validates URL
password: SecretStr # masked in str() / repr()
birthdate: date
created_at: datetime
EmailStr and HttpUrl need extras:
Nested models¶
class Address(BaseModel):
street: str
city: str
pin: str = Field(..., pattern=r"^\d{6}$")
class Company(BaseModel):
name: str
address: Address
employees: list[str] = []
c = Company(
name="Acme",
address={"street": "MG Rd", "city": "Bangalore", "pin": "560001"},
employees=["Alice", "Bob"],
)
Optional and nullable fields¶
class Profile(BaseModel):
name: str
bio: str | None = None # optional, may be None
twitter: str = "" # optional, defaults to empty string
skills: list[str] = [] # optional, defaults to empty list
Rule of thumb: default to
Nonefor "missing / unknown",[]or""for "explicitly empty".
Model configuration¶
Control behavior with model_config:
from pydantic import BaseModel, ConfigDict
class User(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True, # auto-strip spaces from strings
str_to_lower=False,
frozen=False, # immutability
extra="forbid", # reject unknown fields
# extra="allow" to keep extras
# extra="ignore" (default) to drop silently
)
name: str
age: int
extra="forbid" is great for catching typos in keys.
Strict mode — no coercion¶
Pydantic v2 doesn't auto-convert "25" to 25 for int fields by default (good!). Even stricter — turn off ANY coercion:
from pydantic import BaseModel, Field
class Item(BaseModel):
quantity: int = Field(strict=True)
Item(quantity=5) # ok
# Item(quantity=5.0) # rejected
# Item(quantity="5") # rejected
Aliases — different field names in / out¶
from pydantic import BaseModel, Field
class User(BaseModel):
user_id: int = Field(alias="userID") # incoming JSON has "userID"
full_name: str = Field(alias="fullName")
# Accept either alias or actual name
u = User.model_validate({"userID": 1, "fullName": "Alice"})
print(u.user_id, u.full_name)
# Dump using aliases
print(u.model_dump(by_alias=True))
# {'userID': 1, 'fullName': 'Alice'}
Great for matching external API conventions while keeping snake_case in Python.
Converting to/from JSON & dicts¶
class Movie(BaseModel):
title: str
year: int
m = Movie(title="Inception", year=2010)
m.model_dump() # dict
m.model_dump_json() # JSON string
m.model_dump_json(indent=2) # pretty JSON
Movie.model_validate({"title": "Tenet", "year": 2020}) # from dict
Movie.model_validate_json('{"title":"Tenet","year":2020}') # from JSON
Serializers — control how a field is dumped¶
from pydantic import BaseModel, field_serializer
from datetime import datetime
class Event(BaseModel):
name: str
when: datetime
@field_serializer("when")
def fmt_when(self, dt: datetime) -> str:
return dt.strftime("%Y-%m-%d %H:%M")
e = Event(name="Conf", when=datetime(2025, 5, 1, 14, 30))
print(e.model_dump_json())
# {"name":"Conf","when":"2025-05-01 14:30"}
Pydantic v2 model methods cheatsheet¶
| Method | Use |
|---|---|
model.model_dump() |
Convert to dict |
model.model_dump(exclude={"x"}) |
Skip fields |
model.model_dump(exclude_unset=True) |
Only fields that were explicitly set |
model.model_dump(by_alias=True) |
Use aliases |
model.model_dump_json() |
Convert to JSON string |
Model.model_validate(d) |
Build from dict |
Model.model_validate_json(s) |
Build from JSON |
Model.model_json_schema() |
Get the JSON schema |
Model.model_fields |
Inspect declared fields |
How Pydantic powers FastAPI¶
When you write:
FastAPI:
- Reads the type hint
User. - Auto-generates the request schema for Swagger docs.
- Validates incoming JSON against the model — before your function runs.
- Builds a
Userinstance and passes it in. - Uses the model again for the response schema if you set
response_model=.
You write Pydantic models. FastAPI does the rest.
Common pitfalls¶
- ❗ Mixing v1 and v2 syntax — v1:
@validator,Configclass,.dict(). v2:@field_validator,model_config,.model_dump(). Use v2 for new code. - ❗ Forgetting
@classmethodon validators — required in v2. - ❗ Using
extra="allow"as the default — silently lets clients send junk fields. Prefer"forbid"or default"ignore". - ❗
Field(..., default=0)—...means required. Don't passdefault=with it. - ❗ Validators that mutate the wrong thing — return the validated value, don't modify
selfinside afield_validator.
What's next¶
Practice¶
What does this print?
Expected: Alice
Use a Pydantic validator to enforce age > 0 (no negative ages)
Expected: True
Quiz — Quick check¶
What you remember
Q1. What does Field(ge=0) do?
- Constraint: value must be >= 0; otherwise raises ValidationError
- Makes the field required
- Sets a default
- Marks it as a foreign key
Why:
Field(ge=N)means "greater than or equal to N". Other constraints:gt,le,lt,min_length,max_length,pattern(regex), etc. All show up in OpenAPI docs.
Q2. How do you make a field optional?
- Give it a default:
name: str | None = Noneorname: str = "default" -
optional: True -
nullable: True - Wrap in a function
Why: A default value (including
None) makes the field optional. Without a default, Pydantic requires it.
Q3. What's the difference between @field_validator and @model_validator?
-
field_validatorvalidates one field;model_validator(mode="after")validates relationships between fields - No difference
-
model_validatoris deprecated -
field_validatoronly works on strings
Why: Use
field_validatorfor "is this email valid?". Usemodel_validator(mode="after")for "do start_date and end_date make sense together?"—needs access to multiple fields.
Common doubts¶
Should I use Pydantic v1 or v2?
v2 for new code. It's faster, stricter, better designed. Migration from v1 is mostly mechanical (renames: Config → ConfigDict, @validator → @field_validator). The Pydantic v2 migration guide covers everything.
When should I use Pydantic outside FastAPI?
Anywhere you need data validation: config files (replace argparse + manual validation), API responses (validate untrusted external data), CLI tools (validate args). Pydantic is great wherever you have data that needs schema.
How do I serialize a Pydantic model to JSON?
model.model_dump_json() returns a JSON string. model.model_dump() returns a dict. To customize: mode="json" for JSON-friendly types, exclude={"password"} to drop fields, by_alias=True for aliased field names.