Backend / Web frameworks / Pydantic / 03_validation_modes.md

Validation modes: before, after, strict, lax

Updated 5 interview angles 3 min read source
On this page5
  1. Before and after
  2. Model validators take the same modes
  3. Strict and lax
  4. Related
  5. Interview angle

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

text
raw input ──▶ [before] ──▶ Pydantic coercion ──▶ [after] ──▶ field

after 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.

python
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 v

mode="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:

python
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 v

Note 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

python
@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 self

before 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.

python
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 a date field, because in strict mode a string is not a date. Strict belongs behind the boundary, not on it.

Interview angle 5

  • before or after validators?” - after is the default and receives the parsed, typed value, which is where business rules belong. before sees 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 before compares against a value that has not been parsed yet, and a normaliser in after never 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 date field. 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 after mode runs once every field has validated individually.