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:

  1. BeforeValidator (Annotated) and field_validator(mode="before") — order: declaration order.
  2. Type coercion / parsing.
  3. AfterValidator (Annotated) and field_validator(mode="after").

For the model:

  1. model_validator(mode="before").
  2. Field validation (per above).
  3. 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 .