Custom Validators

intermediate fastapi pydantic

Field() handles simple constraints. For anything fancier — “password must contain a digit AND a special char”, “end_date must be after start_date”, “username must not be a banned word” — we need custom validators.

In simple language — a validator is a method on the model that runs during parsing. We get the value, we either return it (possibly transformed) or raise a ValueError.

Two kinds of validators

@field_validator
runs on ONE field
Use when the rule only looks at one value.
Example: password complexity
@model_validator
runs on the WHOLE model
Use when the rule needs multiple fields.
Example: end_date > start_date

@field_validator — single field

The classic case: validating password strength.

from pydantic import BaseModel, field_validator
import re

class SignUp(BaseModel):
    username: str
    password: str

    @field_validator("password")
    @classmethod
    def password_must_be_strong(cls, v: str) -> str:
        if len(v) < 8:
            raise ValueError("password must be at least 8 chars")
        if not re.search(r"\d", v):
            raise ValueError("password must contain a digit")
        if not re.search(r"[!@#$%^&*]", v):
            raise ValueError("password must contain a special character")
        return v

Things to notice:

  • @classmethod is required in v2 (it’s gone in v1).
  • We return the value — we can transform it (lowercase it, strip it) and the returned value is what gets stored.
  • Raise ValueError, not HTTPException. Pydantic catches it and bundles it into the 422 response.

Validate multiple fields at once

Pass several field names to the same validator:

@field_validator("username", "email")
@classmethod
def no_whitespace(cls, v: str) -> str:
    if " " in v:
        raise ValueError("must not contain spaces")
    return v.lower()

mode=“before” vs mode=“after”

By default, validators run after Pydantic has done its type coercion. Sometimes we want to run before — to handle raw input ourselves.

class Event(BaseModel):
    tags: list[str]

    @field_validator("tags", mode="before")
    @classmethod
    def split_string(cls, v):
        # Accept "python,fastapi,backend" as well as a list
        if isinstance(v, str):
            return [t.strip() for t in v.split(",")]
        return v

mode="before" gets raw input. mode="after" (default) gets the already-typed value.

@model_validator — cross-field rules

When the rule needs two or more fields, @field_validator can’t help — each only sees its own value. Use @model_validator instead.

from pydantic import BaseModel, model_validator
from datetime import date

class Booking(BaseModel):
    start_date: date
    end_date: date
    guests: int

    @model_validator(mode="after")
    def check_dates(self) -> "Booking":
        if self.end_date <= self.start_date:
            raise ValueError("end_date must be after start_date")
        if self.guests > 1 and (self.end_date - self.start_date).days < 1:
            raise ValueError("multi-guest bookings need at least 1 night")
        return self

With mode="after", the validator receives self — a fully-built model instance. We return self (or a modified one). With mode="before", we receive the raw dict before any field validation runs.

Validators run in order

Field validators run in field declaration order. Model validators run after all field validators. Knowing this matters when one field depends on another being already validated.

Validation pipeline
1. Type coercion (str → int, etc.)
2. Field() constraints (min/max/regex)
3. @field_validator in declaration order
4. @model_validator(mode="after")
Validated model returned

The takeaway — use @field_validator for single-field rules with logic too custom for Field(). Reach for @model_validator only when fields must agree with each other.