Pydantic models in FastAPI
Pydantic itself is Pydantic — validation modes, settings,
the model config. This is the part FastAPI adds: a model in a signature becomes
the request contract, and a model in response_model becomes the response
contract, enforced.
In means parse, out means filter
class OrderIn(BaseModel):
sku: str
qty: int = Field(gt=0, le=100)
class OrderOut(BaseModel):
id: int
sku: str
total: Decimal
model_config = ConfigDict(from_attributes=True)
@app.post("/orders", response_model=OrderOut, status_code=201)
async def create(body: OrderIn) -> OrderOut:
# an ORM object is fine
return await service.create(body)Two different jobs. OrderIn parses and rejects — a missing field or
qty=0 never reaches your code, and the client gets a 422 describing exactly
which field failed. OrderOut filters and guarantees — whatever the
service returns, only these three fields leave the process.
from_attributes=True is what lets the ORM row be returned directly:
FastAPI validates it by reading attributes rather than requiring a dict.
The response model is a security boundary
# Without response_model: everything the ORM carries goes out.
# hashed_password included
@app.get("/users/{id}")
async def get(id: int) -> User:
return await repo.get(id)That is the leak. A separate output model cannot emit a field it does not
declare, no matter who edits the ORM model later or what relationship someone
adds. Relying on exclude={"hashed_password"} is a list somebody has to
maintain; a model is a guarantee.
Gotcha: if the handler returns something that does not satisfy
response_model, FastAPI raisesResponseValidationErrorand the client gets a 500 — it does not quietly drop the extra field or fill the missing one. That is correct: you published a contract and broke it. See Exception handling.
Where the parameters come from
@app.get("/orders/{order_id}")
async def read(
order_id: int, # path
q: str | None = None, # query
limit: Annotated[int, Query(le=100)] = 20, # query, validated
x_tenant: Annotated[str, Header()] = "", # header
body: OrderIn | None = None, # body
): ...The default rule: named in the path is a path parameter, a Pydantic model is
the body, a bare scalar is a query parameter. Query, Header, Cookie,
Form and File override that when the inference is wrong or you want
validation on it.
For a group of related query parameters, a model beats five arguments:
class Page(BaseModel):
limit: int = Field(20, le=100)
offset: int = 0
@app.get("/orders")
async def list_(page: Annotated[Page, Query()]): ...Nested models and the response shape
class Line(BaseModel):
sku: str
qty: int
class OrderOut(BaseModel):
id: int
lines: list[Line]
@app.get("/orders", response_model=list[OrderOut])
async def list_(): ...Nesting works in both directions and the whole tree is validated. list[Model]
as a response_model is the shape for a collection — and note it validates
every element, which on a large page is real CPU. That cost is the argument for
pagination rather than for skipping the model.
Excluding and including
@app.get("/orders/{id}", response_model=OrderOut,
response_model_exclude_none=True)| Option | Does |
|---|---|
response_model_exclude_none |
drops null fields from the payload |
response_model_exclude_unset |
drops fields the caller never set |
response_model_by_alias |
serialise by alias, default True |
exclude_unset is the one that matters for PATCH: it distinguishes “the
client sent null deliberately” from “the client did not mention this field”,
which is exactly the semantic difference between PUT and PATCH in
PUT vs PATCH: Understanding the Difference.
# only what was actually sent
patch = body.model_dump(exclude_unset=True)
for k, v in patch.items():
setattr(order, k, v)Aliases, for an API that is not snake_case
class OrderOut(BaseModel):
order_id: int = Field(alias="orderId")
model_config = ConfigDict(populate_by_name=True)alias sets the wire name; populate_by_name=True lets you construct it with
the Python name too, which you almost always want or your own tests become
awkward. For a whole model, alias_generator=to_camel beats annotating every
field.
Related
Interview angle 6
- “Why have separate input and output models?” - they do different jobs. The input model parses and rejects, so bad data never reaches your code. The output model filters, so it cannot emit a field it does not declare — which is a guarantee rather than an
excludelist someone has to maintain. - “What happens if the handler returns the wrong shape?” -
ResponseValidationErrorand a 500. FastAPI does not silently drop extras or fill gaps, because you published a contract and broke it; the bug is server-side. - “How does FastAPI decide a parameter is a query parameter?” - by inference: named in the path is a path parameter, a Pydantic model is the body, a bare scalar is a query parameter.
Query,Header,CookieandFormoverride that and add validation. - “How do you implement PATCH properly?” -
model_dump(exclude_unset=True), which returns only the fields the client actually sent. That distinguishes an explicitnullfrom an absent field, which is the whole difference between PUT and PATCH. - “What is
from_attributesfor?” - it lets a model validate an object by reading attributes instead of requiring a dict, so a handler can return an ORM row directly and the response model still filters it. - “What does
response_model=list[Model]cost?” - it validates every element. On a large collection that is real CPU, which is an argument for pagination rather than for dropping the model.