Validation modes: before, after, strict, lax
Two independent axes, routinely confused. Before/after is when your validator runs relative to Pydantic’s own parsing. Strict/lax is whether Pydantic coerces at all.
Before and after
raw input ──▶ [before] ──▶ Pydantic coercion ──▶ [after] ──▶ fieldafter is the default. Your validator receives the value already coerced
to the field’s type, which is what you want most of the time — you are checking
a business rule, not parsing.
class Product(BaseModel):
price: Decimal
@field_validator("price") # mode="after"
@classmethod
def positive(cls, v: Decimal) -> Decimal:
if v <= 0:
raise ValueError("must be positive")
# v is a Decimal here
return vmode="before" receives the raw input, whatever it is, and runs instead of
letting Pydantic see it first. Reach for it only when the input needs
normalising into something the parser can handle:
class Product(BaseModel):
price: Decimal
@field_validator("price", mode="before")
@classmethod
def strip_currency(cls, v):
if isinstance(v, str):
return v.replace("£", "").replace(",", "")
# v may be anything
return vNote the isinstance check. A before validator has no type guarantee at all,
so it must handle whatever arrives — that is the cost of running first.
before |
after |
|
|---|---|---|
| Receives | raw input, any type | the parsed value |
| Default | no | yes |
| For | normalising, formats | business rules |
| Must guard types | yes | no |
Getting this backwards is a real bug: a business rule in before compares
against a string it assumed was an int, and a normaliser in after never
runs because parsing already rejected the input.
Model validators take the same modes
@model_validator(mode="before")
@classmethod
def unwrap_envelope(cls, data):
# a dict, pre-parse
return data.get("payload", data)
@model_validator(mode="after")
def dates_ordered(self):
if self.end <= self.start: # a real model
raise ValueError("end must be after start")
return selfbefore gets the raw dict, which is how you reshape a payload — unwrapping an
envelope, renaming a legacy key. after gets the constructed model with every
field typed, which is where cross-field rules belong.
Strict and lax
Lax is the default: Pydantic coerces where it safely can. Strict rejects anything that is not already the right type.
class Lax(BaseModel):
n: int
class Strict(BaseModel):
n: int
model_config = ConfigDict(strict=True)
Lax(n="42").n # 42
Strict(n="42") # ValidationError| Source | Choose |
|---|---|
| JSON body, query string, env var | lax — everything arrives as a string |
| Internal message between services | strict — a type mismatch is a bug |
| A single field you care about | Field(strict=True) |
strict is available per field, per model and per call
(model_validate(data, strict=True)), so you can be strict about an amount and
lax about everything else.
Gotcha: JSON has no integers-versus-floats distinction the way Python does, and no dates at all. A strict model fed a JSON body rejects
"2026-01-01"for adatefield, because in strict mode a string is not a date. Strict belongs behind the boundary, not on it.
Related
Interview angle 5
- “
beforeoraftervalidators?” -afteris the default and receives the parsed, typed value, which is where business rules belong.beforesees the raw input and is for normalising unusual formats — and it has no type guarantee, so it must check what it got. - “What happens if you pick the wrong one?” - a business rule in
beforecompares against a value that has not been parsed yet, and a normaliser inafternever runs because parsing already rejected the input. - “Strict or lax mode?” - lax coerces where it safely can, which is what you need at a boundary where everything arrives as a string. Strict rejects type mismatches, which is right for internal contracts where a mismatch signals a bug.
- “Why not use strict everywhere?” - JSON has no date type, so a strict model rejects an ISO date string for a
datefield. Strict belongs behind the parsing boundary, not on it. - “Field-level or model-level validation?” - field-level for one value in isolation, model-level for rules spanning fields such as end date after start date. A model validator in
aftermode runs once every field has validated individually.