Chapter 3: Pydantic v2 as FastAPI’s validation engine. We cover the BaseModel, fields, validators, error formatting, and the patterns that make request validation safe and ergonomic. For Pydantic itself in depth, see the Pydantic v2 Textbook .
BaseModel basics
from pydantic import BaseModel, Field, EmailStr
class UserCreate(BaseModel):
email: EmailStr
full_name: str = Field(..., min_length=1, max_length=120)
age: int | None = Field(default=None, ge=0, le=150)
tags: list[str] = Field(default_factory=list, max_length=10)
Use this in a route:
@app.post("/users")
async def create_user(user: UserCreate):
return {"name": user.full_name, "email": user.email}
FastAPI reads JSON, validates against UserCreate. Bad input → 422 with details.
Field
Field adds metadata:
default/default_factory.- Validation:
min_length,max_length,ge,gt,le,lt,pattern. - Documentation:
description,examples,title. - Aliasing:
alias,validation_alias,serialization_alias.
class Item(BaseModel):
sku: str = Field(..., pattern=r"^[A-Z]{3}-\d{4}$", description="SKU like ABC-1234")
price_cents: int = Field(..., ge=0, description="Price in cents")
Validators
from pydantic import field_validator, model_validator
class Order(BaseModel):
quantity: int
unit_price: float
@field_validator("quantity")
@classmethod
def quantity_positive(cls, v):
if v <= 0:
raise ValueError("quantity must be positive")
return v
@model_validator(mode="after")
def total_under_limit(self):
if self.quantity * self.unit_price > 1_000_000:
raise ValueError("order too large")
return self
field_validator for single fields; model_validator for cross-field. mode="before" runs before parsing; mode="after" runs after.
Type system
Pydantic supports a wide range of types:
- Primitives:
int,float,str,bool,bytes. - Collections:
list,dict,tuple,set,frozenset. - Standard library:
datetime,date,time,timedelta,Decimal,UUID,Path. - Networking:
EmailStr,IPvAnyAddress,HttpUrl,AnyUrl. - Constrained:
conint,confloat,constr,conlist. - Generic:
TypeVar-based generics. - Discriminated unions.
- Annotated types.
For richer constrained types, prefer Annotated:
from typing import Annotated
from pydantic import StringConstraints
Username = Annotated[str, StringConstraints(min_length=3, max_length=32, pattern=r"^[a-z0-9_]+$")]
class UserCreate(BaseModel):
username: Username
Reusable; composable.
Optional vs nullable
class M(BaseModel):
a: str | None # nullable, but required
b: str | None = None # nullable AND optional (defaults to None)
c: str = "default" # required-with-default
Subtle. Match exactly what the JSON contract says.
Aliases
When request JSON keys don’t match Python conventions:
class User(BaseModel):
full_name: str = Field(alias="fullName")
model_config = {"populate_by_name": True}
JSON {"fullName": "Alice"} → User(full_name="Alice"). With populate_by_name=True, both forms work.
For separate validation vs serialization aliases:
class User(BaseModel):
user_id: int = Field(validation_alias="id", serialization_alias="userId")
Common when adapting external schemas.
Nested models
class Address(BaseModel):
street: str
city: str
country: str
class UserCreate(BaseModel):
name: str
addresses: list[Address]
Deep validation. Errors point to the failing path.
Discriminated unions
from typing import Literal, Annotated
from pydantic import Field
class Cat(BaseModel):
kind: Literal["cat"]
meows_per_day: int
class Dog(BaseModel):
kind: Literal["dog"]
barks_per_day: int
Animal = Annotated[Cat | Dog, Field(discriminator="kind")]
class Pet(BaseModel):
name: str
animal: Animal
Pydantic uses kind to dispatch. Faster + clearer errors than untagged unions.
Validation errors
FastAPI converts Pydantic errors to 422:
{
"detail": [
{
"type": "missing",
"loc": ["body", "email"],
"msg": "Field required",
"input": {...}
}
]
}
The loc array tells you where: body, query, path, header, cookie, then the field path.
Custom error responses
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def custom_validation_exception_handler(request, exc):
return JSONResponse(
status_code=400,
content={
"error": "validation_error",
"details": [{"field": ".".join(map(str, e["loc"][1:])), "message": e["msg"]} for e in exc.errors()],
},
)
Override the default 422 format if you need a different envelope.
Strict mode
By default, Pydantic v2 is fairly forgiving (string "5" → int(5)). For stricter:
class M(BaseModel):
model_config = {"strict": True}
age: int # now requires actual int, not "5"
Or per field:
from pydantic import StrictInt
class M(BaseModel):
age: StrictInt
Strict mode catches more class of bugs at the API boundary.
Computed fields
from pydantic import computed_field
class User(BaseModel):
first: str
last: str
@computed_field
@property
def full(self) -> str:
return f"{self.first} {self.last}"
Serialized; not accepted on input.
Settings
For app config:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
redis_url: str
secret_key: str
model_config = {"env_file": ".env", "env_prefix": "MYAPP_"}
Environment vars become attributes. Used widely as a Depends.
Examples in docs
class UserCreate(BaseModel):
email: EmailStr
age: int = Field(ge=0)
model_config = {
"json_schema_extra": {
"examples": [
{"email": "[email protected]", "age": 30},
{"email": "[email protected]", "age": 25},
]
}
}
Surfaces in Swagger UI as ready-to-try examples.
Forbid extra fields
class M(BaseModel):
model_config = {"extra": "forbid"}
name: str
Unknown keys → validation error. Useful for strict APIs.
Default is "ignore". Other option: "allow" (extras stored on the model).
Frozen / immutable
class M(BaseModel):
model_config = {"frozen": True}
name: str
m = M(name="x")
m.name = "y" # ValidationError
For value-object semantics.
JSON schema
schema = UserCreate.model_json_schema()
Returns the JSON schema. FastAPI uses this to build OpenAPI.
Performance
Pydantic v2 is Rust-cored and fast. For most APIs, validation isn’t the bottleneck. If profiling shows it is:
- Avoid huge unions without discriminator.
- Cache Pydantic models at module scope.
- Consider msgspec for ultra-hot paths (different ergonomics; smaller ecosystem).
Re-using models
For Create / Update / Read shapes:
class UserBase(BaseModel):
email: EmailStr
full_name: str
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
email: EmailStr | None = None
full_name: str | None = None
class UserRead(UserBase):
id: int
created_at: datetime
Inheritance for the common fields. Distinct shapes for input vs output.
Common mistakes
1. Returning a SQLAlchemy model directly
Sometimes works (with from_attributes=True). Often leaks fields. Use a Read Pydantic model and let FastAPI serialize via response_model.
2. Validators that mutate without returning
@field_validator("name")
@classmethod
def lowercase(cls, v):
v.lower() # bug: doesn't return!
Always return.
3. Cross-field validation in field_validator
Use model_validator for cross-field. field_validator doesn’t see other fields reliably.
4. Forgetting populate_by_name
When using aliases, your code expects model.full_name but JSON has fullName. With default config, Python name doesn’t work for input. Set populate_by_name=True for both.
5. Sharing models across input and DB
Pydantic models for I/O; SQLAlchemy models for DB. Don’t conflate.
What’s next
Chapter 4 covers response models and serialization in depth.
Read this next
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .