Built-in types and Field constraints cover a lot. When they don’t, Pydantic
gives you two custom hooks that people mix up: field_validator and
model_validator. The names are the rule. One sees a single field. The other
sees the whole object — after (or before) fields are in play.
field_validator: local rules
Use a field validator when the check depends only on that value (plus maybe some context you pass in). Normalize whitespace, reject empty strings, parse a slug, strip a currency symbol. The error path points at one location, which is what API clients and CSV loaders want.
from pydantic import BaseModel, field_validator
class Product(BaseModel):
sku: str
name: str
@field_validator("sku")
@classmethod
def normalize_sku(cls, v: str) -> str:
v = v.strip().upper()
if not v:
raise ValueError("sku must not be empty")
return v
Prefer constraints (min_length, pattern,
Annotated types) when they express the rule. Reach for
field_validator when you need custom normalization or a message the
constraint cannot say cleanly.
model_validator: cross-field rules
Use a model validator when the rule needs more than one field:
end_date >= start_date, “discount requires a coupon,” “exactly one of
email or phone.” Those checks do not belong on a single field — they belong on the
object.
from pydantic import BaseModel, model_validator
class Booking(BaseModel):
start: str
end: str
@model_validator(mode="after")
def check_range(self) -> Booking:
if self.end < self.start:
raise ValueError("end must be on or after start")
return self
mode="after" runs once fields are validated and typed — the usual place for
domain checks. mode="before" sees the raw input; reserve it for reshaping
messy payloads before field parsing, not for everyday business rules.
What goes wrong when you mix them up
Stuffing cross-field logic into a field_validator forces awkward
ValidationInfo / data access and unclear error locations. Running a
single-field normalize in a model_validator hides which input was wrong and
makes reuse harder.
Also remember: assignment after construction does not re-run these hooks the way
model_validate does. Custom validators protect the boundary you validate at
— not every later mutate.
A simple decision
One value → field_validator (or a constraint). Several values →
model_validator. Keep each hook small. If the rule needs a database or an
HTTP call, that is application logic after parse — not something to bury inside model
construction.