Custom Validators — field_validator and model_validator in FastAPI

Pydantic validators in FastAPI request schemas do three things that FastAPI’s built-in type validation cannot: transform input (normalise email to lowercase, generate a slug from a title), validate relationships between fields (end date must be after start date), and apply domain-specific rules (slugs must be unique per author, passwords must meet complexity requirements). In FastAPI, these validators run automatically when a request is received — invalid data produces a descriptive 422 response with the validator’s error message, and valid data arrives in your handler already normalised and clean.

Field Validators in Request Schemas

from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Self
import re

class PostCreate(BaseModel):
    title: str = Field(..., min_length=3, max_length=200)
    body:  str = Field(..., min_length=10)
    slug:  str | None = None   # auto-generated from title if not provided
    tags:  list[str] = []

    @field_validator("title")
    @classmethod
    def normalise_title(cls, v: str) -> str:
        v = v.strip()
        if len(v) < 3:
            raise ValueError("Title must be at least 3 characters after stripping whitespace")
        return v   # return the stripped version

    @field_validator("slug", mode="before")
    @classmethod
    def generate_or_validate_slug(cls, v: str | None) -> str | None:
        if v is None:
            return v   # let model_validator handle auto-generation from title
        # Normalise: lowercase, replace spaces with hyphens, remove invalid chars
        slug = re.sub(r"[^\w\s-]", "", v.lower())
        slug = re.sub(r"[\s_]+", "-", slug)
        slug = re.sub(r"-+", "-", slug).strip("-")
        if len(slug) < 3:
            raise ValueError("Slug must be at least 3 characters")
        return slug

    @field_validator("tags")
    @classmethod
    def normalise_tags(cls, tags: list[str]) -> list[str]:
        # Lowercase, strip whitespace, remove duplicates, sort
        cleaned = list({tag.strip().lower() for tag in tags if tag.strip()})
        if len(cleaned) > 10:
            raise ValueError("Maximum 10 tags allowed")
        return sorted(cleaned)

    @model_validator(mode="after")
    def auto_generate_slug(self) -> Self:
        if self.slug is None and self.title:
            # Generate slug from title
            slug = re.sub(r"[^\w\s-]", "", self.title.lower())
            slug = re.sub(r"[\s_]+", "-", slug)
            self.slug = re.sub(r"-+", "-", slug).strip("-")
        return self
Note: @field_validator with mode="before" runs before Pydantic’s type coercion — the value arrives as whatever the client sent (raw JSON value). With mode="after" (default), it runs after type validation — the value is already the declared Python type. Use mode="before" for transformations that depend on the raw input (normalising strings before length validation), and the default for validators that need the value to already be the correct type.
Tip: Create reusable Annotated type aliases for common validation patterns across your application. Define NonEmptyStr = Annotated[str, Field(min_length=1), BeforeValidator(str.strip)] in a app/schemas/types.py module, then use it everywhere: title: NonEmptyStr. When you need to change the minimum length for all names across your API, you change it in one place. These reusable types are covered in Chapter 12 and are the foundation of a maintainable schema layer.
Warning: Validator error messages become part of the 422 response body that clients receive — make them informative but not security-sensitive. A message like "Password must be at least 8 characters" is fine. But "Email alice@example.com already exists in the database" leaks information about other users (an enumeration vulnerability). For uniqueness constraints, catch the database error at the handler level and return a generic 409 Conflict response rather than validating uniqueness inside a Pydantic validator (which would require a database query in the validator itself).

Cross-Field Validation with model_validator

from pydantic import BaseModel, model_validator
from datetime import datetime
from typing import Self

class EventCreate(BaseModel):
    title:      str
    starts_at:  datetime
    ends_at:    datetime
    max_guests: int | None = None
    is_public:  bool = True

    @model_validator(mode="after")
    def validate_dates_and_capacity(self) -> Self:
        # Cross-field: ends_at must be after starts_at
        if self.ends_at <= self.starts_at:
            raise ValueError("ends_at must be after starts_at")

        # Cross-field: private events must have max_guests set
        if not self.is_public and self.max_guests is None:
            raise ValueError("Private events must specify max_guests")

        return self

class PasswordChange(BaseModel):
    current_password: str
    new_password:     str
    confirm_password: str

    @model_validator(mode="after")
    def passwords_match(self) -> Self:
        if self.new_password != self.confirm_password:
            raise ValueError("new_password and confirm_password do not match")
        if self.current_password == self.new_password:
            raise ValueError("New password must be different from current password")
        return self

Reusable Annotated Types

from typing import Annotated
from pydantic import Field, BeforeValidator
import re

# ── Reusable validators as standalone functions ───────────────────────────────
def normalise_email(v: str) -> str:
    return v.strip().lower()

def validate_slug(v: str) -> str:
    slug = re.sub(r"[^\w\s-]", "", v.lower().strip())
    slug = re.sub(r"[\s_-]+", "-", slug).strip("-")
    if not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$", slug):
        raise ValueError("Invalid slug format")
    return slug

# ── Annotated type aliases ────────────────────────────────────────────────────
Email    = Annotated[str, BeforeValidator(normalise_email),
                     Field(pattern=r"^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}$")]
Slug     = Annotated[str, BeforeValidator(validate_slug),
                     Field(min_length=3, max_length=200)]
NonEmpty = Annotated[str, BeforeValidator(str.strip), Field(min_length=1)]
PostTitle = Annotated[str, BeforeValidator(str.strip),
                      Field(min_length=3, max_length=200)]

# ── Use in schemas ────────────────────────────────────────────────────────────
class UserCreate(BaseModel):
    email: Email       # validates, normalises, and checks format
    name:  NonEmpty    # strips whitespace, requires non-empty
    slug:  Slug | None = None

class PostCreate(BaseModel):
    title: PostTitle   # stripped and length-checked
    body:  NonEmpty
    slug:  Slug | None = None

Common Mistakes

Mistake 1 — Forgetting @classmethod on field_validator

❌ Wrong — missing @classmethod causes a cryptic error:

@field_validator("title")
def normalise_title(cls, v: str) -> str:   # missing @classmethod!
    return v.strip()

✅ Correct — always add @classmethod:

@field_validator("title")
@classmethod
def normalise_title(cls, v: str) -> str:   # ✓
    return v.strip()

Mistake 2 — Raising plain Exception instead of ValueError in validators

❌ Wrong — Exception not caught by Pydantic:

@field_validator("slug")
@classmethod
def check_slug(cls, v):
    if " " in v:
        raise Exception("Slug cannot contain spaces")   # not caught by Pydantic!

✅ Correct — raise ValueError for validation failures:

raise ValueError("Slug cannot contain spaces")   # ✓ included in 422 response

Mistake 3 — Database checks inside Pydantic validators

❌ Wrong — Pydantic validators should be pure (no DB access):

@field_validator("email")
@classmethod
def check_email_unique(cls, v):
    if db.query(User).filter(User.email == v).first():   # DB inside validator!
        raise ValueError("Email already taken")
# Problem: validator runs before you have a DB session, circular dependencies

✅ Correct — check uniqueness in the handler, raise HTTPException(409).

Quick Reference

Pattern Code
Field validator @field_validator("field") @classmethod def v(cls, val): ...
Before type coercion @field_validator("f", mode="before")
Cross-field validation @model_validator(mode="after") def v(self) -> Self:
Raise validation error raise ValueError("message")
Reusable type Email = Annotated[str, BeforeValidator(fn), Field(...)]

🧠 Test Yourself

A client sends {"title": " Hello ", "body": "Content"}. Your @field_validator("title") @classmethod def v(cls, val): return val.strip() runs. What does the handler receive as post.title?