Cheatsheet for Pydantic v2 validators.

field_validator

from pydantic import BaseModel, field_validator

class M(BaseModel):
    email: str
    age: int

    @field_validator("email", mode="before")
    @classmethod
    def lower_email(cls, v):
        return v.lower().strip() if isinstance(v, str) else v

    @field_validator("age")             # default mode="after"
    @classmethod
    def positive_age(cls, v: int) -> int:
        if v < 0:
            raise ValueError("age must be non-negative")
        return v

mode="before": raw input. mode="after": typed value.

Multi-field validator

@field_validator("email", "alt_email")
@classmethod
def lower(cls, v: str) -> str:
    return v.lower()

Apply same validator to several fields.

ValidationInfo (cross-field)

from pydantic import ValidationInfo

@field_validator("password_confirm")
@classmethod
def passwords_match(cls, v, info: ValidationInfo):
    if v != info.data.get("password"):
        raise ValueError("passwords don't match")
    return v

info.data = already-validated fields (declaration order matters).

model_validator

from pydantic import model_validator

class Order(BaseModel):
    quantity: int
    price: float

    @model_validator(mode="after")
    def total_under_limit(self):
        if self.quantity * self.price > 1_000_000:
            raise ValueError("order too large")
        return self

mode="after": typed model; access typed attrs.

mode="before": raw dict; transform input.

model_validator mode=‘before’

@model_validator(mode="before")
@classmethod
def normalize(cls, data):
    if isinstance(data, dict):
        if "name" in data and isinstance(data["name"], str):
            data["name"] = data["name"].strip()
    return data

Annotated validators (reusable)

from typing import Annotated
from pydantic import BeforeValidator, AfterValidator

def lowercase(v: str) -> str:
    return v.lower()

def positive(v: int) -> int:
    if v <= 0: raise ValueError("must be positive")
    return v

LowercaseEmail = Annotated[str, AfterValidator(lowercase)]
PositiveInt = Annotated[int, AfterValidator(positive)]

class User(BaseModel):
    email: LowercaseEmail
    age: PositiveInt

Composable; reusable.

WrapValidator (wrap default)

from pydantic import WrapValidator
from pydantic import ValidatorFunctionWrapHandler
from typing import Any

def trim_then_validate(v: Any, handler: ValidatorFunctionWrapHandler) -> str:
    if isinstance(v, str): v = v.strip()
    return handler(v)              # delegate to original

TrimmedStr = Annotated[str, WrapValidator(trim_then_validate)]

PlainValidator (replace default)

from pydantic import PlainValidator

def my_validate(v):
    return str(v).strip()

class M(BaseModel):
    name: Annotated[str, PlainValidator(my_validate)]

No default validation; only your function.

Context-aware validation

@field_validator("email")
@classmethod
def check(cls, v, info: ValidationInfo):
    if info.context and info.context.get("check_db"):
        if email_exists(v):
            raise ValueError("email taken")
    return v

User.model_validate({"email": "..."}, context={"check_db": True})

Validator order

  1. mode="before" validators (Annotated + field_validator).
  2. Type coercion / parsing.
  3. mode="after" validators.

For the whole model:

  1. model_validator(mode="before").
  2. Field validation (per above).
  3. model_validator(mode="after").

Raise ValueError, not ValidationError

@field_validator("age")
@classmethod
def positive(cls, v):
    if v < 0:
        raise ValueError("age must be non-negative")   # converted to ValidationError with loc
    return v

Don’t raise ValidationError directly from validators.

Use cases

Normalize

@field_validator("phone", mode="before")
@classmethod
def normalize(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

Conditional required

@model_validator(mode="after")
def kind_specific(self):
    if self.type == "alpha" and not self.alpha_field:
        raise ValueError("alpha_field required when type=alpha")
    return self

Common mistakes

  • Forgetting to return v — silently sets None.
  • Cross-field check in field_validator — use model_validator instead.
  • Heavy work / DB lookup in validators — slow + impure.
  • Mutating input dict in model_validator(mode="before") — sometimes works, sometimes not.

Read this next

If you want my reusable validator library, it’s at rajpoot.dev .


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 .