Files
2026-06-01 21:42:05 +00:00

4.3 KiB

Pydantic Boundaries

Use Pydantic models deliberately at FastAPI transport boundaries. They are excellent for parsing, validation, and serialization, but they should not automatically become your entire domain model.

Why this convention exists

FastAPI and Pydantic fit together especially well at request and response boundaries:

  • parsing external input
  • validating shape and types
  • coercing raw values
  • serializing output
  • generating OpenAPI schema

That convenience is the point. The trap is letting one transport model silently become the request model, response model, persistence model, and domain model for accidental reasons rather than deliberate ones.

The convention

  1. Use Pydantic models for request and response boundaries.
  2. Validate external input explicitly.
  3. Serialize explicitly at the response boundary.
  4. Translate between transport models and domain or persistence objects when the concerns diverge.
  5. Keep field-level validation and coercion on the schema that owns those rules.

Follow it when data crosses the API boundary

Typical fits:

  • request bodies
  • response models
  • field aliasing or defaulting rules
  • API shapes that should stay stable even if internal objects change

Do not split models just to satisfy a slogan

A tiny app may genuinely use the same shape all the way through.

But split models once the concerns stop matching, especially when:

  • API names diverge from internal names
  • persistence fields should not leak outward
  • the response shape is not the storage shape
  • business invariants deserve their own representation

Preferred shapes

Request model at the boundary, explicit translation inward

@router.post("/items", response_model=ItemPublic)
def create_item(session: SessionDep, current_user: CurrentUser, item_in: ItemCreate) -> ItemPublic:
    item = Item.model_validate(item_in, update={"owner_id": current_user.id})
    session.add(item)
    session.commit()
    session.refresh(item)
    return item

This keeps request parsing explicit while still allowing a translation step into the stored object.

Explicit partial-update semantics

update_dict = item_in.model_dump(exclude_unset=True)

That is clearer than guessing which missing fields mean "leave unchanged."

Counterexamples

One transport schema leaked into every layer

class UserPayload(BaseModel):
    ...

# same class used for HTTP input, DB persistence, internal domain state,
# background jobs, and every outbound response

Sometimes fine for a toy app; usually brittle over time.

Ad hoc dict shaping in handlers

return {"id": item.id, "name": item.name, "owner": current_user.email}

Bad because the response contract drifts away from any explicit schema.

Validation rules smeared across unrelated layers

If field coercion belongs to the request schema, keep it there instead of duplicating it in routes, services, and ORM hooks.

Source signals

Pydantic

  • pydantic/main.py:253-264 states that model construction parses and validates input data and raises ValidationError when the input cannot form a valid model.
  • pydantic/docs/index.md:68-107 shows external data becoming a typed model and then being serialized with model_dump().
  • pydantic/docs/index.md:109-152 shows invalid input producing structured validation errors.
  • pydantic/docs/concepts/validators.md:91-114 shows field-scoped mode='after' validation.
  • pydantic/docs/concepts/validators.md:160-252 shows mode='before' validators operating on raw input before normal parsing.

Full-stack FastAPI Template

  • backend/app/api/routes/items.py:61-72 accepts ItemCreate, translates it into an Item, commits it, and returns the stored object.
  • backend/app/api/routes/items.py:44-45 converts query results into ItemPublic response models for collection responses.
  • backend/app/api/routes/items.py:91-95 uses model_dump(exclude_unset=True) before applying a patch-style update.

FastAPI

  • fastapi/exception_handlers.py:20-26 turns request validation failures into a consistent 422 response instead of leaving each route to invent its own error format.

Bottom line

Pydantic belongs at the HTTP boundary by default.

Use it there aggressively.

When transport concerns and domain concerns diverge, translate between them instead of forcing one class to own every job forever.