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.
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
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
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
# 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
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 selfField 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.
Related
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-settingswithBaseSettings, constructed at import so a bad variable fails at boot, andSecretStrfor 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.