Unit 05.04: Custom validators and where they belong
Some rules involve one field and some involve the relationship between two.
Field validators and model validators
A date range with a per-field rule and a cross-field rule.
The code rejects an inverted range and an out-of-bounds date.
from pydantic import BaseModel, field_validator, model_validator, ValidationError
from datetime import date
class Period(BaseModel):
start: date
end: date
@field_validator("start", "end")
@classmethod
def not_in_future(cls, v: date) -> date:
if v > date(2026, 12, 31):
raise ValueError("must not be in the future")
return v
@model_validator(mode="after")
def end_after_start(self):
if self.end < self.start:
raise ValueError("end must not be before start")
return self
print(Period(start=date(2026, 1, 1), end=date(2026, 6, 30)))
for payload in [{"start": date(2026, 6, 30), "end": date(2026, 1, 1)},
{"start": date(2027, 1, 1), "end": date(2027, 6, 1)}]:
try:
Period(**payload)
except ValidationError as exc:
print(f"rejected: {exc.errors()[0]['msg']}")
# A field validator sees one value; a model validator sees the whole object and
# is where cross-field rules belong. Both keep the rule in the contract rather
# than in a handler that some other endpoint will forget to call.
A field validator sees one value and is the right place for a bound. A model validator runs after all fields are parsed and can see the relationship - which is where "end must not be before start" belongs.
Both keep the rule in the contract rather than in a handler, so every endpoint accepting this model enforces it and none can forget.
The mistake this prevents
The mistake is enforcing cross-field rules in the handler because a field validator cannot see the other field. Reach for the model validator - the rule then travels with the type wherever it is used.
Takeaway
Field validators for single values, model validators for relationships between fields. Both keep the rule with the type rather than in one handler.
