| name | pydantic |
| description | Pydantic v2 patterns for request/response schemas, validators, and data conversion in FastAPI services. Load when defining BaseModel classes, designing API schemas, writing field validators, or converting between SQLAlchemy models and Pydantic schemas. Use alongside `fastapi` and `pydantic-settings` (the latter handles env vars specifically). |
Pydantic v2
Pydantic v2 is the validation and serialisation layer for every backend service. The split is rigid: SQLAlchemy models live in app/models/, Pydantic schemas live in app/schemas/, and the two never import each other.
Why two parallel hierarchies
- ORM models reflect database shape (nullable for storage reasons, FK columns, internal flags).
- API schemas reflect contract shape (what clients send and receive).
- Conflating them leaks internal fields to clients, makes schema evolution painful, and ties API stability to migration timing.
When in doubt: the schema is the contract; the model is the storage.
Base patterns
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, ConfigDict
class UserBase(BaseModel):
email: EmailStr
name: str = Field(min_length=1, max_length=200)
class UserCreate(UserBase):
password: str = Field(min_length=12, max_length=200)
class UserUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
email: EmailStr | None = None
class UserRead(UserBase):
id: str
tenant_id: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
Rules:
- Three schema types per resource:
Create, Update, Read. Compose them via inheritance from a Base if they share fields.
Update schemas use | None defaults so PATCH semantics work — only set fields are updated.
Read schemas use from_attributes=True so FastAPI's response_model can construct them directly from a SQLAlchemy row.
- No password fields in
Read schemas. Ever. The fact that this is even possible is why Read and Create exist as separate types.
Field constraints
Use Field(...) for everything the database also constrains. The schema is the first layer of defence; the DB is the second.
from pydantic import BaseModel, Field, HttpUrl, conint, constr
class ProjectCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=10_000)
homepage: HttpUrl | None = None
priority: int = Field(ge=0, le=100)
Use the dedicated types (EmailStr, HttpUrl, IPvAnyAddress) instead of regex strings. They are tested and the failure messages are clear.
Validators
Use field_validator for single-field rules and model_validator for cross-field rules.
from pydantic import BaseModel, field_validator, model_validator
class DateRange(BaseModel):
start: datetime
end: datetime
@field_validator("start", "end")
@classmethod
def must_be_utc(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("must be timezone-aware")
return v.astimezone(timezone.utc)
@model_validator(mode="after")
def end_after_start(self) -> "DateRange":
if self.end <= self.start:
raise ValueError("end must be after start")
return self
Rules:
mode="after" for cross-field validation — runs after individual fields are coerced.
- Always raise
ValueError. Pydantic converts it to a ValidationError with the right field path. Do not raise HTTPException — that is the route's job.
- Validators are pure. No DB calls, no side effects, no I/O.
Discriminated unions
Use them for polymorphic payloads instead of optional fields.
from typing import Annotated, Literal
from pydantic import BaseModel, Field
class EmailNotification(BaseModel):
kind: Literal["email"] = "email"
to: EmailStr
subject: str
class SmsNotification(BaseModel):
kind: Literal["sms"] = "sms"
to: str
body: str
Notification = Annotated[
EmailNotification | SmsNotification,
Field(discriminator="kind"),
]
The OpenAPI spec generated by FastAPI will document these as a proper oneOf with a discriminator, and the frontend's generated types will be a tagged union — no casts.
Converting between models and schemas
- Model → schema for responses: let FastAPI do it. Set
response_model=UserRead on the route and return the SQLAlchemy row. from_attributes=True makes this work.
- Schema → model for inserts: explicit field mapping in the service, never
**payload.model_dump() blindly. Blind unpacking lets new schema fields silently set unintended model attributes.
user = User(
tenant_id=current_user.tenant_id,
email=payload.email,
name=payload.name,
password_hash=hash_password(payload.password),
)
user = User(**payload.model_dump())
Serialisation
model_dump() for plain dicts (e.g. logging).
model_dump_json() for JSON strings (e.g. cache values, queue payloads).
exclude_none=True when sending to APIs that distinguish "absent" from "null".
- Never
dict(model) — it does not recurse into nested models.
Common mistakes
- Returning a Pydantic schema from a service — services return ORM models or domain dataclasses. The route turns them into schemas.
from_attributes=True on Create schemas — pointless. Only Read schemas need it.
- Raising
HTTPException from a validator — wrong layer. Raise ValueError.
- Sharing one base schema for both
Create and Read — leads to leaking fields. Always split.
- Using
BaseSettings from pydantic — moved to pydantic-settings package in v2. See the pydantic-settings skill.
- Using
**model.model_dump() to construct an ORM row — fragile. Map fields explicitly.