Chapter 3: validators. The hooks for custom validation logic, normalization, and cross-field checks.
field_validator
from pydantic import BaseModel, field_validator
class User(BaseModel):
email: str
age: int
@field_validator("email")
@classmethod
def email_lowercase(cls, v: str) -> str:
return v.lower()
@field_validator("age")
@classmethod
def age_positive(cls, v: int) -> int:
if v < 0:
raise ValueError("age must be non-negative")
return v
Returns the value (possibly transformed) or raises ValueError.
mode=‘before’ vs ‘after’
@field_validator("age", mode="before")
@classmethod
def coerce_age(cls, v):
if isinstance(v, str):
return int(v.strip())
return v
before: runs on raw input before type coercion.
after: runs after Pydantic has typed the value (default).
Use before for normalization / coercion. after for business validation on typed values.
Multiple fields
@field_validator("email", "alt_email")
@classmethod
def lowercase(cls, v: str) -> str:
return v.lower()
Same validator for multiple fields.
model_validator
For cross-field validation:
from pydantic import model_validator
class Order(BaseModel):
quantity: int
unit_price: float
@model_validator(mode="after")
def total_under_limit(self):
if self.quantity * self.unit_price > 1_000_000:
raise ValueError("order too large")
return self
mode="after": model is fully validated; access typed attrs.
mode="before": receives raw dict input.
Before-mode model_validator
@model_validator(mode="before")
@classmethod
def normalize(cls, data: dict) -> dict:
if "name" in data and isinstance(data["name"], str):
data["name"] = data["name"].strip()
return data
Runs on raw input; can transform before field-level validation.
ValidationInfo
For accessing context / siblings:
from pydantic import ValidationInfo
@field_validator("password_confirm")
@classmethod
def passwords_match(cls, v: str, info: ValidationInfo) -> str:
if v != info.data.get("password"):
raise ValueError("passwords don't match")
return v
info.data has fields validated so far. Order is declaration order.
context
@field_validator("email")
@classmethod
def check_email_unused(cls, v: str, info: ValidationInfo) -> str:
if info.context and info.context.get("check_db"):
if email_exists(v):
raise ValueError("email already used")
return v
# Pass context at validation time:
User.model_validate({"email": "..."}, context={"check_db": True})
For validation that depends on external state.
Annotated validators
from typing import Annotated
from pydantic import BeforeValidator, AfterValidator
def lowercase(v: str) -> str:
return v.lower()
LowercaseEmail = Annotated[str, AfterValidator(lowercase)]
class User(BaseModel):
email: LowercaseEmail
BeforeValidator runs before parsing; AfterValidator after. Composable as Annotated metadata.
For validators with messages:
def positive(v: int) -> int:
if v <= 0:
raise ValueError("must be positive")
return v
PositiveInt = Annotated[int, AfterValidator(positive)]
Reusable across fields / models.
WrapValidator
from pydantic import WrapValidator
from typing import Any
from pydantic import ValidatorFunctionWrapHandler
def trim_then_validate(v: Any, handler: ValidatorFunctionWrapHandler) -> str:
if isinstance(v, str):
v = v.strip()
return handler(v) # delegate to original validator
TrimmedStr = Annotated[str, WrapValidator(trim_then_validate)]
Wrap the default validator with custom logic.
PlainValidator
For replacing the default validator entirely:
from pydantic import PlainValidator
def my_validator(v):
return str(v).strip()
class M(BaseModel):
field: Annotated[str, PlainValidator(my_validator)]
No default validation runs; only your function.
Order of validators
For a single field:
BeforeValidator(Annotated) andfield_validator(mode="before")— order: declaration order.- Type coercion / parsing.
AfterValidator(Annotated) andfield_validator(mode="after").
For the model:
model_validator(mode="before").- Field validation (per above).
model_validator(mode="after").
Predictable and stable.
Strict per validator
@field_validator("count")
@classmethod
def is_int(cls, v) -> int:
if not isinstance(v, int):
raise ValueError("must be int")
return v
Use mode="before" to run before coercion; check exact type.
ValidationError vs ValueError
In validators: raise ValueError (or AssertionError). Pydantic converts to ValidationError with a proper loc.
Don’t raise ValidationError directly from validators; it’s for top-level.
Use cases
Normalization
@field_validator("phone", mode="before")
@classmethod
def normalize_phone(cls, v):
if isinstance(v, str):
return re.sub(r"\D", "", v)
return v
Cross-field
@model_validator(mode="after")
def end_after_start(self):
if self.end_date <= self.start_date:
raise ValueError("end must be after start")
return self
External lookup
@field_validator("category_id")
@classmethod
def category_exists(cls, v: int, info: ValidationInfo) -> int:
if info.context:
db = info.context.get("db")
if db and not db.has_category(v):
raise ValueError("category not found")
return v
Validating computed_field
computed_field doesn’t accept input; no validation needed. For derived values that need post-conditions, use model_validator(mode="after").
Common mistakes
1. Forgetting to return
@field_validator("name")
@classmethod
def lower(cls, v):
v.lower() # bug: doesn't return
Always return.
2. Cross-field in field_validator
@field_validator("end_date")
@classmethod
def after_start(cls, v, info):
if v <= info.data.get("start_date"): # may not be there yet
raise ValueError(...)
Use model_validator(mode="after") instead.
3. Mutating input dict
@model_validator(mode="before")
@classmethod
def fix_keys(cls, data):
data["x"] = data["X"] # ok if data is a fresh dict
return data
Pydantic copies, but defensive copy is safer.
4. Heavy work in validators
DB queries / HTTP in validators: slow validation. Validators are pure functions over input; do external lookups elsewhere.
5. Missing @classmethod
@field_validator("x")
def check(self, v): # wrong; needs @classmethod
...
Must be classmethod (Pydantic v2 requirement).
What’s next
Chapter 4: Serialization in depth.
Read this next
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .