23 KiB
Production Pydantic v2 Guide for FastAPI (2025)
Pydantic v2 represents a complete architectural overhaul from v1, with a Rust-powered core (pydantic-core) that delivers 5-17x performance improvements. The API has been redesigned around Annotated types, new validator patterns, and explicit serialization modes. This guide focuses on 2025 best practices—not tutorial basics—with emphasis on patterns that scale in production FastAPI applications.
Core v2 Changes That Actually Matter
The Rust Core Changes Everything
Pydantic v2's validation engine is written in Rust and exposed via PyO3. This isn't just "faster validation"—it fundamentally changes how you should think about data processing:
# v1 pattern (deprecated)
from pydantic import BaseModel, validator
class User(BaseModel):
age: int
@validator('age')
def check_age(cls, v):
if v < 18:
raise ValueError('too young')
return v
# v2 pattern (correct)
from pydantic import BaseModel, field_validator
class User(BaseModel):
age: int
@field_validator('age')
@classmethod
def check_age(cls, v: int) -> int:
if v < 18:
raise ValueError('too young')
return v
Key differences:
@field_validatorreplaces@validator—the signature is now type-safe- Validators are explicitly
@classmethoddecorated - No more
each_itemkeyword; useAnnotatedfor collection items
Method Name Changes You Must Know
| v1 Method | v2 Replacement | Purpose |
|---|---|---|
.dict() |
.model_dump() |
Serialize to dict |
.json() |
.model_dump_json() |
Serialize to JSON string |
parse_obj() |
.model_validate() |
Validate dict/object |
parse_raw() |
.model_validate_json() |
Validate JSON string |
from_orm() |
.model_validate() + from_attributes=True |
Load from ORM models |
Critical: The old v1 methods still exist but are deprecated. Using them in new code is a mistake.
Annotated: The New Foundation
Annotated is now the primary way to attach metadata to fields. This creates reusable, composable type aliases:
from typing import Annotated
from pydantic import BaseModel, Field, AfterValidator
# Reusable validated types
PositiveInt = Annotated[int, Field(gt=0)]
NonEmptyStr = Annotated[str, Field(min_length=1)]
EmailStr = Annotated[str, Field(pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')]
# Composable validation
def check_even(v: int) -> int:
if v % 2 != 0:
raise ValueError('must be even')
return v
EvenPositiveInt = Annotated[int, Field(gt=0), AfterValidator(check_even)]
class Product(BaseModel):
quantity: PositiveInt
sku: NonEmptyStr
batch_size: EvenPositiveInt
Why this matters: Type aliases centralize validation logic. Change PositiveInt once, and every field using it updates automatically.
Field Metadata vs Validation
from pydantic import Field
class Article(BaseModel):
# Metadata: describes the field, doesn't validate
title: str = Field(
description="Article title",
examples=["How to Scale FastAPI"],
json_schema_extra={"maxLength": 200}
)
# Constraints: actually validate
word_count: int = Field(gt=0, le=10000)
tags: list[str] = Field(min_length=1, max_length=10)
Metadata appears in OpenAPI/JSON schema but doesn't enforce anything. Constraints raise ValidationError on violation.
Validators: The Four Modes
Pydantic v2 has four validator types with explicit execution order:
1. Before Validators
Run before Pydantic's type coercion. Handle raw input:
from pydantic import BaseModel, field_validator
class Event(BaseModel):
timestamp: datetime
@field_validator('timestamp', mode='before')
@classmethod
def parse_timestamp(cls, v):
# v could be str, int, datetime, anything
if isinstance(v, str):
return datetime.fromisoformat(v)
if isinstance(v, int):
return datetime.fromtimestamp(v)
return v # Let Pydantic handle it
Use when: You need to preprocess raw data before type checking.
2. After Validators (default)
Run after Pydantic validates the type. Input is guaranteed to match the type annotation:
from pydantic import field_validator
class User(BaseModel):
username: str
@field_validator('username') # mode='after' is default
@classmethod
def normalize_username(cls, v: str) -> str:
return v.lower().strip()
Use when: You need to transform already-validated data.
3. Wrap Validators
Run around Pydantic's validation—you control whether to call the handler:
from pydantic import WrapValidator, ValidationError
from typing import Annotated
def truncate_or_fail(v, handler):
try:
return handler(v) # Try normal validation
except ValidationError as e:
if e.errors()[0]['type'] == 'string_too_long':
return v[:100] # Truncate instead of failing
raise
LenientStr = Annotated[str, Field(max_length=100), WrapValidator(truncate_or_fail)]
class Post(BaseModel):
title: LenientStr
Use when: You need to catch validation errors and recover.
4. Plain Validators
Replace Pydantic's validation entirely. Dangerous but sometimes necessary:
from pydantic import PlainValidator
from typing import Annotated
def custom_validation(v):
# No type checking happens—you're responsible for everything
return int(v) * 2
WeirdInt = Annotated[int, PlainValidator(custom_validation)]
class Model(BaseModel):
number: WeirdInt
print(Model(number='5').number) # 10
print(Model(number='invalid').number) # 'invalidinvalid' (no error!)
Use when: Pydantic's type system can't express your validation logic.
Annotated Validators vs Decorator Syntax
Both styles work; choose based on reusability:
# Annotated: reusable across models
AgeInt = Annotated[int, AfterValidator(lambda v: v if v >= 18 else 18)]
class User(BaseModel):
age: AgeInt
# Decorator: model-specific logic
class User(BaseModel):
age: int
@field_validator('age')
@classmethod
def min_age(cls, v: int) -> int:
return v if v >= 18 else 18
Accessing Other Fields in Validators
Use ValidationInfo to access previously validated fields:
from pydantic import field_validator, ValidationInfo
class PasswordReset(BaseModel):
password: str
password_confirm: str
@field_validator('password_confirm')
@classmethod
def passwords_match(cls, v: str, info: ValidationInfo) -> str:
if 'password' in info.data and v != info.data['password']:
raise ValueError('passwords do not match')
return v
Critical: Field order matters. password must be defined before password_confirm.
Model Serialization: Python vs JSON Mode
Pydantic v2 has two serialization modes with different outputs:
from datetime import datetime
from pydantic import BaseModel
class Event(BaseModel):
name: str
occurred_at: datetime
tags: set[str]
event = Event(name='Launch', occurred_at='2025-01-01', tags={'python', 'api'})
# Python mode: preserves Python types
print(event.model_dump())
# {'name': 'Launch',
# 'occurred_at': datetime.datetime(2025, 1, 1, 0, 0),
# 'tags': {'python', 'api'}}
# JSON mode: only JSON-compatible types
print(event.model_dump(mode='json'))
# {'name': 'Launch',
# 'occurred_at': '2025-01-01T00:00:00',
# 'tags': ['api', 'python']}
Use JSON mode when:
- Sending data to FastAPI response models
- Storing in Redis/DynamoDB (requires JSON)
- Passing to
json.dumps()yourself
Use Python mode when:
- Manipulating data in Python
- Passing to other Python functions
- Need access to Python objects (datetime, UUID, etc.)
The serialize_as_any Gotcha
In v2, nested subclass fields are serialized according to the annotated type, not the runtime type:
class User(BaseModel):
name: str
class AdminUser(User):
permissions: list[str]
class Company(BaseModel):
employees: list[User]
admin = AdminUser(name='Alice', permissions=['admin'])
company = Company(employees=[admin])
# v2 default: only User fields serialized
print(company.model_dump())
# {'employees': [{'name': 'Alice'}]} # permissions missing!
# To get v1 behavior, use serialize_as_any
print(company.model_dump(serialize_as_any=True))
# {'employees': [{'name': 'Alice', 'permissions': ['admin']}]}
Fix: Annotate with SerializeAsAny if you need duck-typing:
from pydantic import SerializeAsAny
class Company(BaseModel):
employees: list[SerializeAsAny[User]] # Now includes subclass fields
Computed Fields: Properties That Serialize
Computed fields are properties that automatically appear in model_dump():
from pydantic import BaseModel, computed_field
class Rectangle(BaseModel):
width: float
height: float
@computed_field
@property
def area(self) -> float:
return self.width * self.height
rect = Rectangle(width=10, height=5)
print(rect.area) # 50.0
print(rect.model_dump()) # {'width': 10.0, 'height': 5.0, 'area': 50.0}
Critical differences from regular properties:
- Included in
model_dump()andmodel_dump_json() - Appear in JSON schema (marked
readOnly) - Cannot be set during initialization
- Only support
alias, notvalidation_alias/serialization_alias
Computed Fields with Setters
from pydantic import computed_field
class Square(BaseModel):
width: float
@computed_field
@property
def area(self) -> float:
return self.width ** 2
@area.setter
def area(self, value: float):
self.width = value ** 0.5
square = Square(width=4)
square.area = 25 # Sets width to 5.0
print(square.model_dump()) # {'width': 5.0, 'area': 25.0}
Use case: Exposing derived data in APIs without storing it.
ConfigDict: Model-Level Configuration
ConfigDict replaces the v1 Config class:
from pydantic import BaseModel, ConfigDict, Field
class StrictModel(BaseModel):
model_config = ConfigDict(
strict=True, # No type coercion
frozen=True, # Immutable (replaces allow_mutation=False)
validate_assignment=True, # Validate on attribute assignment
extra='forbid', # Reject extra fields
from_attributes=True, # Enable ORM mode
str_strip_whitespace=True, # Strip strings automatically
use_attribute_docstrings=True, # Use docstrings as descriptions
)
age: int
"""User's age in years"""
Strict Mode Explained
By default, Pydantic coerces types (lax mode):
class User(BaseModel):
age: int
print(User(age='25').age) # 25 (int, coerced from str)
Strict mode disables coercion:
class User(BaseModel):
model_config = ConfigDict(strict=True)
age: int
User(age='25') # ValidationError: Input should be a valid integer
Per-field override:
class User(BaseModel):
model_config = ConfigDict(strict=True)
age: int
name: str = Field(strict=False) # Only this field allows coercion
from_attributes: The New ORM Mode
Replaces v1's orm_mode=True:
from pydantic import ConfigDict
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class User(DeclarativeBase):
__tablename__ = 'users'
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str]
class UserSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: str
# Load from SQLAlchemy model
db_user = session.get(User, 1)
user_data = UserSchema.model_validate(db_user)
Critical: Without from_attributes=True, you can only validate dicts.
TypeAdapter: Validation for Non-Model Types
TypeAdapter validates any type, not just BaseModel:
from pydantic import TypeAdapter, ValidationError
from typing import TypedDict
class UserDict(TypedDict):
name: str
age: int
UserListAdapter = TypeAdapter(list[UserDict])
# Validate and coerce
data = [{'name': 'Alice', 'age': '30'}]
users = UserListAdapter.validate_python(data)
print(users) # [{'name': 'Alice', 'age': 30}]
# Serialize
json_bytes = UserListAdapter.dump_json(users)
Use cases:
- Validating API responses (don't want full
BaseModel) - Working with
TypedDictor dataclasses - Generic container types (
list[int],dict[str, float])
TypeAdapter for Constrained Types
from pydantic import Field
from typing import Annotated
PositiveInt = Annotated[int, Field(gt=0)]
adapter = TypeAdapter(PositiveInt)
adapter.validate_python(5) # OK
adapter.validate_python(-5) # ValidationError
Discriminated Unions: Type-Safe Polymorphism
Discriminated unions are FastAPI's secret weapon for handling polymorphic request/response data:
from typing import Literal, Union
from pydantic import BaseModel, Field
class Cat(BaseModel):
type: Literal['cat']
meows: int
class Dog(BaseModel):
type: Literal['dog']
barks: int
class Pet(BaseModel):
pet: Union[Cat, Dog] = Field(discriminator='type')
# Efficient validation—checks 'type' field first
pet = Pet.model_validate({'pet': {'type': 'cat', 'meows': 5}})
print(type(pet.pet)) # <class '__main__.Cat'>
How Discriminators Improve Performance
Without discriminator, Pydantic tries each union member sequentially:
# Slow: tries Cat, fails, tries Dog
pet: Union[Cat, Dog]
With discriminator, Pydantic jumps directly to the correct type:
# Fast: reads 'type' field, validates only Dog
pet: Union[Cat, Dog] = Field(discriminator='type')
Production win: On unions with 5+ members, discriminators are 10x+ faster.
Nested Discriminated Unions
class BlackCat(BaseModel):
species: Literal['cat']
color: Literal['black']
black_name: str
class WhiteCat(BaseModel):
species: Literal['cat']
color: Literal['white']
white_name: str
Cat = Annotated[Union[BlackCat, WhiteCat], Field(discriminator='color')]
class Dog(BaseModel):
species: Literal['dog']
name: str
Pet = Annotated[Union[Cat, Dog], Field(discriminator='species')]
Fallback Pattern for Unknown Types
Perfect for webhook handlers that must not crash:
from typing import Annotated
class GenericEvent(BaseModel):
type: str
data: dict
class UserCreated(BaseModel):
type: Literal['user.created']
user_id: int
KnownEvents = Annotated[Union[UserCreated], Field(discriminator='type')]
# Outer union tries discriminated first, falls back to generic
Event = Annotated[
Union[KnownEvents, GenericEvent],
Field(union_mode='left_to_right')
]
# Unknown type → GenericEvent (doesn't crash)
Event.model_validate({'type': 'unknown', 'data': {}})
Use case: Third-party APIs that add new event types without notice.
Alias Patterns: validation_alias vs serialization_alias
Separate aliases for input and output:
from pydantic import BaseModel, Field
class User(BaseModel):
# Accept both snake_case and camelCase on input
first_name: str = Field(validation_alias='firstName')
# Always output as camelCase
last_name: str = Field(
validation_alias='lastName',
serialization_alias='lastName'
)
# Validate with camelCase
user = User(firstName='John', lastName='Doe')
# Serialize with snake_case (default) or camelCase
print(user.model_dump()) # {'first_name': 'John', 'last_name': 'Doe'}
print(user.model_dump(by_alias=True)) # {'first_name': 'John', 'lastName': 'Doe'}
AliasPath: Extract Nested Fields
from pydantic import AliasPath
class User(BaseModel):
first_name: str = Field(validation_alias=AliasPath('name', 'first'))
last_name: str = Field(validation_alias=AliasPath('name', 'last'))
city: str = Field(validation_alias=AliasPath('address', 'city'))
# Validate nested structure
user = User.model_validate({
'name': {'first': 'Jane', 'last': 'Doe'},
'address': {'city': 'NYC', 'zip': '10001'}
})
print(user.model_dump())
# {'first_name': 'Jane', 'last_name': 'Doe', 'city': 'NYC'}
Use case: Flattening deeply nested API responses.
AliasChoices: Multiple Valid Aliases
from pydantic import AliasChoices
class User(BaseModel):
username: str = Field(
validation_alias=AliasChoices('username', 'user', 'login')
)
# All of these work
User(username='alice')
User(user='alice')
User(login='alice')
Use case: Supporting legacy API versions during migration.
Global Alias Generators
from pydantic import ConfigDict, AliasGenerator
def to_camel(field: str) -> str:
components = field.split('_')
return components[0] + ''.join(x.title() for x in components[1:])
class User(BaseModel):
model_config = ConfigDict(
alias_generator=AliasGenerator(
validation_alias=str.lower, # Accept any case
serialization_alias=to_camel # Output camelCase
)
)
first_name: str
last_name: str
user = User(FIRST_NAME='John', last_name='Doe')
print(user.model_dump(by_alias=True)) # {'firstName': 'John', 'lastName': 'Doe'}
FastAPI Integration Patterns
Request/Response Schema Separation
Anti-pattern: Using the same model for input and output:
# DON'T DO THIS
class User(BaseModel):
id: int # Who sets this on creation?
email: str
password: str # Leaked in responses!
Correct pattern:
class UserCreate(BaseModel):
email: str
password: str = Field(min_length=8)
class UserUpdate(BaseModel):
email: str | None = None
password: str | None = Field(None, min_length=8)
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: str
created_at: datetime
@router.post('/users', response_model=UserResponse)
async def create_user(data: UserCreate):
user = User(email=data.email, password=hash_password(data.password))
session.add(user)
await session.commit()
return user # FastAPI uses response_model to serialize
The response_model Double Validation Trap
FastAPI validates your return value twice:
@router.get('/', response_model=UserResponse)
def get_user():
user = UserResponse(id=1, email='test@example.com') # Validation #1
return user # Validation #2 (FastAPI converts to dict, validates again)
This is intentional: FastAPI guarantees response_model matches output, even if you return the wrong type.
Performance note: If you return the exact response_model type, the cost is minimal. If you return a dict/ORM object, full validation runs.
Depend on Pydantic for FastAPI Dependencies
from pydantic import BaseModel, Field
from typing import Annotated
from fastapi import Depends, Query
class Pagination(BaseModel):
page: int = Field(1, ge=1)
size: int = Field(20, ge=1, le=100)
def get_pagination(
page: Annotated[int, Query(ge=1)] = 1,
size: Annotated[int, Query(ge=1, le=100)] = 20
) -> Pagination:
return Pagination(page=page, size=size)
@router.get('/items')
def list_items(pagination: Annotated[Pagination, Depends(get_pagination)]):
# pagination is validated
return {'page': pagination.page, 'size': pagination.size}
Anti-Patterns to Avoid
Don't Use mutable Defaults
# WRONG
class Team(BaseModel):
members: list[str] = [] # Shared between instances!
# CORRECT
class Team(BaseModel):
members: list[str] = Field(default_factory=list)
Don't Validate in init
# WRONG
class User(BaseModel):
email: str
def __init__(self, **data):
super().__init__(**data)
# Custom logic here breaks validation context
self.email = self.email.lower()
# CORRECT
class User(BaseModel):
email: str
@field_validator('email')
@classmethod
def normalize_email(cls, v: str) -> str:
return v.lower()
Don't Forget mode='json' for API Responses
# WRONG - datetime not JSON-serializable
return user.model_dump() # Returns datetime objects
# CORRECT
return user.model_dump(mode='json') # Returns ISO strings
Don't Use Optional Without Default
# WRONG - Optional doesn't add a default!
class User(BaseModel):
nickname: Optional[str] # Still required!
# CORRECT
class User(BaseModel):
nickname: str | None = None # Now optional
Performance Optimization
Precompile Models with Large Field Counts
For models with 50+ fields, validation overhead stacks up:
# Reduce overhead by disabling extra checks
class LargeModel(BaseModel):
model_config = ConfigDict(
validate_assignment=False, # Don't re-validate on setattr
validate_default=False, # Skip default value validation
)
Use model_construct for Trusted Data
Skip validation entirely when data is already validated (e.g., from database):
# Slow: full validation
user = UserResponse(**db_row)
# Fast: no validation
user = UserResponse.model_construct(**db_row)
Warning: Only use with 100% trusted data. One bad field corrupts your model.
Cache TypeAdapters
Creating TypeAdapter instances is expensive. Cache them:
from functools import lru_cache
@lru_cache
def get_adapter(type_: type) -> TypeAdapter:
return TypeAdapter(type_)
# Reuse adapter
adapter = get_adapter(list[int])
adapter.validate_python([1, 2, 3])
Migration Checklist from v1
If you're migrating existing code:
- Replace
.dict()with.model_dump() - Replace
.json()with.model_dump_json() - Replace
@validatorwith@field_validator - Replace
Configclass withConfigDict - Replace
orm_mode=Truewithfrom_attributes=True - Update
root_validatorto@model_validator - Check for
Optionalwithout defaults - Add
mode='json'where needed for serialization - Review discriminated unions for performance
- Test strict mode on critical endpoints
Conclusion
Pydantic v2's Rust core and redesigned API make it the definitive choice for FastAPI data validation in 2025. The migration from v1 requires learning new patterns—Annotated types, validator modes, explicit serialization—but the performance gains and improved type safety justify the investment.
Focus on these production-critical patterns:
- Use
Annotatedfor reusable validation logic - Separate request/response schemas (never share models)
- Apply discriminated unions to speed up polymorphic validation
- Choose
mode='json'for API responses,mode='python'for internal processing - Use
from_attributes=Truewhen working with ORMs
The v1 → v2 transition is a one-way door. The old patterns won't receive new features, and some are already deprecated. Build new applications on v2 patterns from day one.