Backend / Web frameworks / FastAPI / 02_pydantic_models.md

Pydantic models in FastAPI

Updated 6 interview angles 4 min read source
On this page8
  1. In means parse, out means filter
  2. The response model is a security boundary
  3. Where the parameters come from
  4. Nested models and the response shape
  5. Excluding and including
  6. Aliases, for an API that is not snake_case
  7. Related
  8. Interview angle

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

python
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

python
# 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 raises ResponseValidationError and 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

python
@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:

python
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

python
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

python
@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.

python
# 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

python
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.

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 exclude list someone has to maintain.
  • “What happens if the handler returns the wrong shape?” - ResponseValidationError and 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, Cookie and Form override 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 explicit null from an absent field, which is the whole difference between PUT and PATCH.
  • “What is from_attributes for?” - 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.