Pydantic models: the contract at the edge of your API
Read and judge Pydantic v2 models — field types, `Field` constraints, `field_validator`, `model_validate` and `model_dump` — and understand why separate request and response models are the thing standing between a database row and a data leak.
13 cards · 6 quiz questions · 10 min read
Every FastAPI codebase an agent writes for you is full of classes that inherit from BaseModel. They look like boilerplate — a list of names and types, no logic — so they are the easiest thing in the repo to skim past. They are also the only place where your API states what it will accept and what it will reveal. Getting them wrong is how a respondent’s email address ends up in a dashboard’s network tab, and how a front-end typo turns into a field that is silently always null.
What a model actually does
Pydantic turns type annotations into runtime enforcement. When FastAPI hands a request body to your model, Pydantic parses the JSON, coerces and checks every field against its annotation, and either gives you a typed Python object or raises a ValidationError that FastAPI converts into a 422 response. It also generates a JSON Schema from the same class, which is what fills in /docs.
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
class ResponseIn(BaseModel):
model_config = ConfigDict(extra="forbid") # unknown keys => 422, not silence
respondent_id: str = Field(min_length=6, max_length=64)
score: Annotated[int, Field(ge=0, le=10, description="NPS-style 0-10")]
channel: Literal["web", "phone", "panel"] # an enum, enforced
comment: str | None = None # genuinely optional
tags: list[str] = Field(default_factory=list)
Three details in there are worth internalising.
Field carries the constraints. ge, le, gt, lt for numbers; min_length, max_length, pattern for strings. These become both runtime checks and part of the OpenAPI schema, so generated TypeScript types on the dashboard side inherit the rules.
comment: str | None = None is optional; comment: str | None is not. The union only makes the field nullable. Without a default, the key must still be present, and its absence produces a 422 with "type": "missing". This single confusion accounts for a large share of mysterious 422s.
default_factory=list rather than = []. Pydantic deep-copies defaults, so a bare [] is not the classic shared-mutable disaster here — but the same habit is a disaster in a dataclass, a function signature or a SQLAlchemy model, and the explicit factory says what you mean.
Custom rules: field and model validators
When a type and a constraint aren’t enough, Pydantic v2 gives you two decorators.
from pydantic import field_validator, model_validator
class WaveWindow(BaseModel):
starts_at: datetime
ends_at: datetime
country: str
@field_validator("country", mode="before") # mode="before" sees the raw input
@classmethod
def upper(cls, v: str) -> str:
return v.strip().upper() # MUST return the value
@model_validator(mode="after") # runs once all fields validated
def check_order(self):
if self.ends_at <= self.starts_at:
raise ValueError("ends_at must be after starts_at")
return self # MUST return self
The rules that matter when reviewing one of these:
@classmethodgoes below@field_validator, and the docs state it is required.- Default mode is
"after": the value has already been coerced to the annotated type."before"sees whatever arrived, which is where you normalise messy input. - Always return the value. A validator that checks and then falls off the end returns
None, so valid input silently becomes null. This is the single most common bug in agent-written validators. - Raise
ValueError, notHTTPException. Pydantic converts aValueErrorinto a proper validation error with the rightloc. model_validator(mode="after")is an instance method takingself;mode="before"is a classmethod taking the raw input dictionary.
Getting data in and out
Three methods do nearly all the work:
| Call | What it does |
|---|---|
Model.model_validate(data) | Validates a dict — or an object, with from_attributes |
Model.model_validate_json(raw) | Validates a JSON string or bytes directly, skipping a separate parse |
instance.model_dump() | Returns a Python dict, possibly containing datetime, Decimal, UUID |
And one to be suspicious of. Model.model_construct(...) builds a model without running validation at all. It exists for data you have already validated. Finding it applied to an LLM’s JSON output, a CSV upload or a third-party API response means nothing is being checked:
# BEFORE — hallucinated types flow straight into the database
extracted = ExtractedFields.model_construct(**llm_json)
# AFTER — validate, and treat failure as a retryable outcome
from pydantic import ValidationError
try:
extracted = ExtractedFields.model_validate(llm_json)
except ValidationError as exc:
log.warning("llm_shape_rejected", extra={"errors": exc.errors(), "raw": llm_json})
return await retry_extraction(document_id)
On the way out, model_dump() and model_dump(mode="json") are not interchangeable. Plain model_dump() keeps Python types, so passing its result to json.dumps or an HTTP client fails with “not JSON serializable” the moment a datetime appears. mode="json" converts values to JSON-safe equivalents — ISO-8601 strings, lists instead of tuples. Useful extras: exclude_none=True, exclude_unset=True (only fields the caller actually set) and by_alias=True.
Request models and response models are different contracts
This is the part agents get wrong most often, and it is the part with security consequences. One model reused for input and output means every field is simultaneously settable by strangers and visible to them.
# BEFORE — one model for everything
class Respondent(BaseModel):
id: int
email: str
cost_usd: float
is_verified: bool
display_name: str
@router.post("/respondents")
async def create(body: Respondent) -> Respondent:
... # caller can set id, cost_usd and is_verified; response returns email
# AFTER — what may be set, and what may be seen
class RespondentCreate(BaseModel): # input: nothing privileged
display_name: str = Field(min_length=1, max_length=120)
class RespondentOut(BaseModel): # output: nothing private
model_config = ConfigDict(from_attributes=True) # read from a SQLAlchemy row
id: int
display_name: str
is_verified: bool
@router.post("/respondents", response_model=RespondentOut, status_code=201)
async def create(body: RespondentCreate, db: SessionDep):
...
The input model excludes id, cost_usd and is_verified because a caller must not be able to set them. The output model excludes email and cost_usd because a caller must not be able to see them. ConfigDict(from_attributes=True) is what lets Pydantic populate the output model by reading attributes off a database row rather than needing a dictionary.
Why returning an ORM object raw is worse than it looks
Beyond the obvious leak — every column, including the ones a future migration adds — serialising an ORM object touches its attributes. Any relationship that wasn’t loaded triggers a fresh query at serialisation time: a burst of N+1 queries, or an outright DetachedInstanceError or MissingGreenlet once the session has closed. A declared response_model reads only the fields you named, which makes the query pattern predictable as well as safe.
Reading a 422 properly
FastAPI’s default handler for a RequestValidationError returns a detail array with one entry per problem:
{"detail": [
{"type": "less_than_equal",
"loc": ["body", "responses", 3, "score"],
"msg": "Input should be less than or equal to 10",
"input": 11},
{"type": "missing", "loc": ["body", "responses", 3, "channel"],
"msg": "Field required", "input": {"score": 11}}
]}
Read loc first, left to right: source (body, query, path, header), then field names and array indices. ["body", "responses", 3, "score"] is the score of the fourth item in the responses array. type is the machine-readable reason — missing, int_parsing, string_too_short, greater_than_equal — and input shows the rejected value. A 422 always means the caller sent the wrong shape; your function never ran. A response model failing, by contrast, is a 500.
One last default worth changing: undeclared fields in the body are ignored silently, so a front end sending waveId to a model expecting wave_id gets a cheerful 200 and a null column. model_config = ConfigDict(extra="forbid") turns that into a 422 and saves a long afternoon.
What to check in your own code
- Grep for endpoints whose request model contains
id,created_at,owner_id,is_adminor anything cost-related — those are fields a caller should not be able to set. - Grep response models for
email,password,hash,token,promptorcost. - Grep for
model_constructand confirm every hit is on already-validated data. - Grep for validators that don’t end in
return. - Grep for
: int | Noneand: str | Nonewith no= None, then check whether the front end sends those keys. - Grep for
model_dump()whose result is passed tojson.dumpsor an HTTP client — it probably wantsmode="json". - In
/docs, click one write endpoint and one read endpoint and check the schemas are genuinely different classes.
Which of these leaks data to the browser?
Sources
- Pydantic — Models website Source for model_validate, model_validate_json, model_construct and ConfigDict(from_attributes=True) for ORM objects.
- Pydantic — Validators website Confirms field_validator modes (before/after/plain/wrap), that @classmethod is required, and model_validator(mode='after') taking self.
- Pydantic — Serialization website Source for model_dump arguments and the difference between mode='python' and mode='json'.
- FastAPI — Handling Errors website Documents RequestValidationError, the 422 response and the detail array with loc, msg and type.