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
@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.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."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(...)] |