Pydantic v2 Models
FastAPI uses Pydantic v2 to handle request validation, response serialization, and settings management. Pydantic v2 was rewritten in Rust and is 5–50x faster than v1.
BaseModel Basics
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import datetime
class User(BaseModel):
id: int
name: str
email: str
age: Optional[int] = None
created_at: datetime = Field(default_factory=datetime.now)
model_config = {
"str_strip_whitespace": True, # strip leading/trailing whitespace
"validate_assignment": True, # validate on assignment too
}
# Create
user = User(id=1, name=" Alice ", email="alice@example.com")
print(user.name) # "Alice" (whitespace stripped)
print(user.model_dump()) # convert to dict
print(user.model_dump_json()) # convert to JSON string
# Parse
data = {"id": "2", "name": "Bob", "email": "bob@test.com"}
user2 = User.model_validate(data) # auto-converts "2" → 2
Field — Field Validation
from pydantic import BaseModel, Field
from typing import Annotated
class Product(BaseModel):
# Required (no default)
name: str = Field(..., min_length=1, max_length=100, description="Product name")
# Numeric range
price: float = Field(..., gt=0, description="Price (greater than 0)")
stock: int = Field(0, ge=0, description="Stock (0 or more)")
discount: float = Field(0.0, ge=0.0, le=1.0, description="Discount rate 0~1")
# Regex validation
sku: str = Field(..., pattern=r"^[A-Z]{3}-\d{4}$", description="e.g. ABC-1234")
# Alias (different JSON key name)
product_type: str = Field(..., alias="type")
# Different name when serializing
internal_code: str = Field(..., serialization_alias="code")
# Using alias
data = {"name": "Python Book", "price": 35000, "sku": "PYT-0001", "type": "book", "code": "B001"}
product = Product.model_validate(data)
print(product.product_type) # "book"
# Serialize with alias
print(product.model_dump(by_alias=True))
field_validator — Custom Validation
from pydantic import BaseModel, Field, field_validator, model_validator
import re
class UserCreate(BaseModel):
username: str
email: str
password: str
confirm_password: str
age: int
# Single field validation
@field_validator("username")
@classmethod
def username_alphanumeric(cls, v: str) -> str:
if not v.isalnum():
raise ValueError("Username must contain only letters and digits")
return v.lower()
@field_validator("email")
@classmethod
def email_format(cls, v: str) -> str:
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, v):
raise ValueError("Invalid email format")
return v.lower()
@field_validator("age")
@classmethod
def age_range(cls, v: int) -> int:
if not (0 <= v <= 150):
raise ValueError(f"Invalid age: {v}")
return v
# Validate multiple fields (model_validator)
@model_validator(mode="after")
def passwords_match(self) -> "UserCreate":
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
# ValidationError on failure
from pydantic import ValidationError
try:
user = UserCreate(
username="alice!!",
email="not-email",
password="secret",
confirm_password="different",
age=200,
)
except ValidationError as e:
for error in e.errors():
print(f"{error['loc']}: {error['msg']}")
Nested Models
from pydantic import BaseModel
from typing import Optional
class Address(BaseModel):
street: str
city: str
country: str = "KR"
zip_code: Optional[str] = None
class Company(BaseModel):
name: str
address: Address
class Employee(BaseModel):
id: int
name: str
company: Company
addresses: list[Address] = []
# Parse nested structure
data = {
"id": 1,
"name": "Alice",
"company": {
"name": "Tech Corp",
"address": {
"street": "123 Main St",
"city": "Seoul",
},
},
"addresses": [
{"street": "Home address", "city": "Seoul"},
],
}
emp = Employee.model_validate(data)
print(emp.company.address.city) # Seoul
# Deep dict conversion
print(emp.model_dump())
Response Model Patterns
from fastapi import FastAPI
from pydantic import BaseModel, Field
from datetime import datetime
app = FastAPI()
# Separate request/response models
class UserCreate(BaseModel):
"""Create request model (includes password)"""
name: str
email: str
password: str
class UserUpdate(BaseModel):
"""Update request model (all fields optional)"""
name: str | None = None
email: str | None = None
class UserResponse(BaseModel):
"""Response model (excludes password)"""
id: int
name: str
email: str
created_at: datetime
model_config = {"from_attributes": True} # auto-convert ORM objects
# response_model_exclude_none: exclude None fields
class PartialResponse(BaseModel):
id: int
name: str
score: float | None = None
rank: int | None = None
@app.get(
"/users/{user_id}",
response_model=PartialResponse,
response_model_exclude_none=True, # don't include None in JSON
)
def get_user(user_id: int):
return PartialResponse(id=user_id, name="Alice", score=None, rank=None)
# Response: {"id": 1, "name": "Alice"} (score, rank excluded)
Summary
| Feature | How |
|---|---|
| Basic validation | Field(min_length=1, gt=0, pattern=...) |
| Custom validation | @field_validator("field") |
| Whole-model validation | @model_validator(mode="after") |
| ORM conversion | model_config = {"from_attributes": True} |
| To dict | model.model_dump() |
| To JSON | model.model_dump_json() |
| Parse | Model.model_validate(data) |
| Exclude None | response_model_exclude_none=True |
Pydantic v2 is the core layer that unifies validation, serialization, and documentation in FastAPI.