🪴 GoDeep
Search
← Shipping Software

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:

  • @classmethod goes 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, not HTTPException. Pydantic converts a ValueError into a proper validation error with the right loc.
  • model_validator(mode="after") is an instance method taking self; mode="before" is a classmethod taking the raw input dictionary.

Getting data in and out

Three methods do nearly all the work:

CallWhat 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_admin or anything cost-related — those are fields a caller should not be able to set.
  • Grep response models for email, password, hash, token, prompt or cost.
  • Grep for model_construct and confirm every hit is on already-validated data.
  • Grep for validators that don’t end in return.
  • Grep for : int | None and : str | None with no = None, then check whether the front end sends those keys.
  • Grep for model_dump() whose result is passed to json.dumps or an HTTP client — it probably wants mode="json".
  • In /docs, click one write endpoint and one read endpoint and check the schemas are genuinely different classes.

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.