Essay

field_validator vs model_validator

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.