Backend / Web frameworks / Pydantic / 02_pydantic_in_practice.md

Pydantic in practice

Updated 5 interview angles 3 min read source
On this page7
  1. One model per direction, not one model
  2. Unknown fields: the setting depends on who sent it
  3. Settings, validated at startup
  4. Where the boundary actually is
  5. Custom rules
  6. Related
  7. Interview angle

Pydantic in practice

Pydantic’s job is the boundary: turn untrusted input into typed objects, or fail with a message that says what was wrong. Everything below follows from using it only there.

One model per direction, not one model

The mistake that costs most: reusing a single User model for the request, the response and the database row.

python
class UserIn(BaseModel):          # what we accept
    email: EmailStr
    password: SecretStr

class UserOut(BaseModel):         # what we return
    id: int
    email: EmailStr
    model_config = ConfigDict(from_attributes=True)

UserOut has no password field, so it cannot leak one — no matter what the ORM object carries, no matter who edits the model later. A shared model makes that a matter of remembering exclude=, which is the same as not having it.

from_attributes=True is what lets UserOut.model_validate(orm_row) read attributes rather than a dict.

Unknown fields: the setting depends on who sent it

python
class StripeEvent(BaseModel):
    # they add fields
    model_config = ConfigDict(extra="ignore")

class InternalJob(BaseModel):
    # we do not
    model_config = ConfigDict(extra="forbid")

A third party adding a field is routine and must not break you. An unexpected field on your own internal message is a typo or a version skew, and you want it loud.

Gotcha: extra="ignore" is the default. If you never set it, a misspelled optional field in your own config is silently discarded and the default is used — which is the settings bug that takes an afternoon to find.

Settings, validated at startup

python
from pydantic import SecretStr
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: PostgresDsn
    api_key: SecretStr
    pool_size: int = 10
    model_config = ConfigDict(extra="forbid")

# raises here, not at first use
settings = Settings()

Two things earn their place. SecretStr renders as ********** in reprs, logs and tracebacks, so a crash report does not carry the key. And constructing Settings() at import means a missing or malformed variable stops the process at boot, where the deploy sees it.

Where the boundary actually is

python
# At the edge: validate once.
order = OrderIn.model_validate(payload)

# Inside: pass the typed object, do not re-validate.
total = price(order.items)

Re-validating the same object in every layer is a real cost on a hot path and buys nothing — it was already validated. If a function needs a guarantee the model does not express, that is a missing field constraint, not a reason to re-run the parser.

For a genuinely hot path over data you constructed yourself, model_construct skips validation entirely. Use it only on data you trust; on external input it is a hole, not an optimisation.

Custom rules

python
class Booking(BaseModel):
    start: date
    end: date

    @field_validator("start", "end")
    @classmethod
    def not_past(cls, v: date) -> date:
        if v < date.today():
            raise ValueError("must not be in the past")
        return v

    @model_validator(mode="after")
    def ordered(self) -> "Booking":
        if self.end <= self.start:
            raise ValueError("end must be after start")
        return self

Field validators for one value, model validators for rules spanning fields. The model validator runs after every field has validated individually, so self is fully typed by then — see Validation modes: before, after, strict, lax.

Interview angle 5

  • “How do you keep API and domain models separate?” - distinct models per direction: request, response and internal. A response model that has no password field cannot leak one; a shared model turns that into a matter of remembering exclude=.
  • “How do you handle unknown fields?” - extra="ignore" for third-party payloads, since providers add fields routinely; extra="forbid" for your own internal APIs and settings, where an unexpected field is a typo or version skew you want to see.
  • “How do you load configuration?” - pydantic-settings with BaseSettings, constructed at import so a bad variable fails at boot, and SecretStr for credentials so they render masked in logs, reprs and tracebacks.
  • “Where should validation happen?” - once, at the boundary. Re-validating the same object in every layer costs real time on a hot path and proves nothing new; if an inner function needs a guarantee, express it as a field constraint.
  • “Field validator or model validator?” - field-level for one value in isolation, model-level for rules spanning fields such as end after start. The model validator runs after all fields have validated, so it works on a fully typed object.