Use @field_validator("field_name") decorator on a classmethod:
from pydantic import BaseModel, field_validator
class User(BaseModel):
username: str
age: int
@field_validator("username")
@classmethod
def username_must_be_alphanumeric(cls, v: str) -> str:
if not v.isalnum():
raise ValueError("username must be alphanumeric")
return v.lower() # transform the value
@field_validator("age")
@classmethod
def age_must_be_positive(cls, v: int) -> int:
if v < 0:
raise ValueError("age must be non-negative")
return v
ValueError messages are caught by Pydantic and included in the ValidationError
detail. You can also raise PydanticCustomError for more structured errors.
Rule of thumb: field validators run per-field; use them for constraints that
can't be expressed with Field() parameters (cross-value logic belongs in
model validators).
mode controls when the validator runs relative to Pydantic's built-in type coercion:
| Mode | Runs | Input type | Use case |
|---|---|---|---|
"before" (default v2) |
before coercion | raw input (str/dict/etc.) | normalize raw strings |
"after" |
after coercion | declared Python type | validate typed value |
"wrap" |
wraps coercion | raw + handler callable | conditional coercion |
"plain" |
replaces coercion | raw input | fully custom type parsing |
from pydantic import BaseModel, field_validator
class Order(BaseModel):
amount: float
@field_validator("amount", mode="before")
@classmethod
def strip_currency_symbol(cls, v):
if isinstance(v, str):
return v.lstrip("$£€") # "£9.99" → "9.99"
return v
Rule of thumb: use mode="before" to normalise raw strings before Pydantic
tries to parse them; use mode="after" for logic that needs the typed value.
Pass multiple field names to @field_validator. The validator is called once
per field listed:
from pydantic import BaseModel, field_validator
class Product(BaseModel):
name: str
description: str
@field_validator("name", "description")
@classmethod
def must_not_be_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("must not be blank or whitespace")
return v.strip()
The validator receives one field value at a time — it does not receive both
simultaneously. For cross-field logic, use @model_validator.
Rule of thumb: share validators across fields with multiple names in the
decorator to keep DRY; use @model_validator when the logic depends on comparing
field values to each other.
@model_validator runs once on the whole model (all fields at once). Use it
for cross-field constraints.
from pydantic import BaseModel, model_validator
class DateRange(BaseModel):
start: date
end: date
@model_validator(mode="after")
def end_after_start(self) -> "DateRange":
if self.end <= self.start:
raise ValueError("end must be after start")
return self
mode="after" — runs after all fields are coerced and validated; self is the
model instance.
mode="before" — runs before field validation; receives raw data as a dict.
Rule of thumb: @field_validator for single-field logic; @model_validator(mode="after")
for cross-field constraints like "end after start" or "confirm password matches".
mode="before" intercepts the raw input before any field parsing. The
validator receives a dict (or arbitrary input) and must return a dict.
from pydantic import BaseModel, model_validator
class FlexibleUser(BaseModel):
name: str
email: str
@model_validator(mode="before")
@classmethod
def coerce_legacy_format(cls, data):
# support both {"name": ...} and {"full_name": ...}
if "full_name" in data and "name" not in data:
data["name"] = data.pop("full_name")
return data
Use cases: renaming legacy fields, setting computed defaults before field validation, accepting multiple input shapes.
Rule of thumb: mode="before" is for input normalisation at the whole-model
level; prefer mode="after" for validation because you get typed values.
Annotate a class with __get_validators__ (v1) or implement __get_pydantic_core_schema__
(v2). For simple cases, use Annotated with AfterValidator:
from typing import Annotated
from pydantic import AfterValidator, BaseModel
def validate_isbn(v: str) -> str:
v = v.replace("-", "")
if len(v) not in (10, 13):
raise ValueError("ISBN must be 10 or 13 digits")
return v
ISBN = Annotated[str, AfterValidator(validate_isbn)]
class Book(BaseModel):
title: str
isbn: ISBN
For full custom types with JSON Schema:
from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema
class PositiveDecimal:
@classmethod
def __get_pydantic_core_schema__(cls, source, handler: GetCoreSchemaHandler):
return core_schema.no_info_after_validator_function(cls, core_schema.decimal_schema())
Rule of thumb: use Annotated[T, AfterValidator(fn)] for simple reusable
validators; implement __get_pydantic_core_schema__ only for complex custom types.
ValidationError has an .errors() method returning a list of error dicts:
from pydantic import BaseModel, ValidationError
class Item(BaseModel):
name: str
price: float
try:
Item(name="", price=-1)
except ValidationError as e:
print(e.errors())
# [
# {"type": "value_error", "loc": ("name",), "msg": "...", "input": ""},
# {"type": "greater_than", "loc": ("price",), "msg": "...", "input": -1}
# ]
Each error has:
type— Pydantic error codeloc— tuple of keys pinpointing the failing fieldmsg— human-readable messageinput— the value that failed
FastAPI captures this and returns it as the 422 response detail array.
Rule of thumb: in unit tests, assert on exc.errors()[0]["type"] to test
specific error codes rather than message strings that may change.
Strict mode disables Pydantic's type coercion — values must already be the correct Python type; no implicit conversion happens.
from pydantic import BaseModel, ConfigDict
class StrictItem(BaseModel):
model_config = ConfigDict(strict=True)
price: float
count: int
StrictItem(price=9.99, count=3) # OK
StrictItem(price="9.99", count=3) # ValidationError — "9.99" is str, not float
Per-field: Field(strict=True) or Strict annotated type.
Use cases: data coming from other Python code (not raw JSON) where silent coercion hides bugs; internal domain objects that must have exact types.
Rule of thumb: don't use strict mode for API request models — clients send JSON where numbers may arrive as strings; use it for internal/DTO models between Python layers.
Pass skip_on_failure=True (for mode="after") or check for None at the top
of the validator. With mode="before" and optional fields, validators run even
on None inputs by default.
from pydantic import BaseModel, field_validator
class Profile(BaseModel):
bio: str | None = None
@field_validator("bio", mode="after")
@classmethod
def bio_max_sentences(cls, v: str | None) -> str | None:
if v is None:
return v # skip check
if v.count(".") > 5:
raise ValueError("bio may not exceed 5 sentences")
return v
Alternatively, in Pydantic v2 you can annotate with Optional inside
Annotated and chain with BeforeValidator that returns None early.
Rule of thumb: always guard against None at the top of validators for
optional fields — a missed check causes a confusing AttributeError.
Yes — the return value of a @field_validator replaces the field's value.
Forgetting to return v silently sets the field to None.
# WRONG — returns None, field becomes None
@field_validator("name")
@classmethod
def check_name(cls, v: str) -> str:
if not v:
raise ValueError("required")
# forgot return v!
# CORRECT
@field_validator("name")
@classmethod
def check_name(cls, v: str) -> str:
if not v:
raise ValueError("required")
return v # must return the (possibly transformed) value
Rule of thumb: end every @field_validator with a return v — validators
that only validate (no transform) still must return the original value.
These are functional wrappers used inside Annotated to attach validators
without subclassing BaseModel:
from typing import Annotated
from pydantic import BaseModel, BeforeValidator, AfterValidator
def strip_spaces(v: str) -> str:
return v.strip()
def ensure_lower(v: str) -> str:
return v.lower()
CleanStr = Annotated[str, BeforeValidator(strip_spaces), AfterValidator(ensure_lower)]
class User(BaseModel):
username: CleanStr
This creates reusable annotated types you can import and use across models without copy-pasting validators.
Rule of thumb: define common transformations as Annotated types (Email,
SlugStr, PositiveDecimal) and import them — it's DRY and keeps validators
out of model classes.
Accept both models as body parameters and validate their relationship in the handler (or in a dedicated service function):
class DateRangeFilter(BaseModel):
start: date
end: date
@model_validator(mode="after")
def end_after_start(self) -> "DateRangeFilter":
if self.end < self.start:
raise ValueError("end must be >= start")
return self
class ReportRequest(BaseModel):
range: DateRangeFilter
metrics: list[str]
@app.post("/reports")
async def generate_report(req: ReportRequest):
...
For cross-model checks that span the body and path/query params, perform the
check in the handler and raise HTTPException(422, ...).
Rule of thumb: encode single-model invariants in @model_validator; encode
cross-model or cross-layer invariants in the handler or service layer.
More Pydantic & Validation interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.