Pydantic is the engine that powers FastAPI’s validation, serialisation, and documentation. Every request body, response model, and settings object in a FastAPI application is a Pydantic model. Pydantic v2 (the current version, used throughout this series) reads your Python class definition with type hints and builds a validation schema from it — invalid data raises a structured ValidationError with field-level details, valid data is coerced to the correct Python types and made available as typed attributes. Understanding Pydantic deeply means understanding FastAPI deeply, because FastAPI delegates almost all data handling to Pydantic.
Basic Pydantic Models
from pydantic import BaseModel, Field
from typing import Literal
from datetime import datetime
class PostCreate(BaseModel):
title: str
body: str
tags: list[str] = []
published: bool = False
# ── Instantiation — validates input ───────────────────────────────────────────
post = PostCreate(title="Hello", body="World")
print(post.title) # "Hello"
print(post.tags) # []
print(post.published) # False
# ── Type coercion ─────────────────────────────────────────────────────────────
# Pydantic coerces compatible types automatically:
post2 = PostCreate(title="Hi", body="There", published="true") # "true" → True
print(post2.published) # True
# ── Validation errors ─────────────────────────────────────────────────────────
from pydantic import ValidationError
try:
bad = PostCreate(title=123, body=None) # body is required (no default)
except ValidationError as e:
print(e.errors())
# [{"loc": ["body"], "msg": "Input should be a valid string", "type": "string_type"}]
# ── Model to dict / JSON ───────────────────────────────────────────────────────
post.model_dump() # {"title": "Hello", "body": "World", "tags": [], ...}
post.model_dump_json() # '{"title":"Hello","body":"World","tags":[],...}'
post.model_dump(exclude={"body"}) # omit body field
post.model_dump(include={"title", "tags"}) # only these fields
post.model_dump(exclude_none=True) # skip None values
model_dump() replaces v1’s .dict() method, and model_dump_json() replaces .json(). Similarly, model_validate() replaces .parse_obj(), and model_validate_json() replaces .parse_raw(). If you see v1 method names in code or tutorials, they may still work (Pydantic v2 has a compatibility layer) but you should use the v2 API for new code. The v1 compatibility layer may be removed in future versions.PostCreate model has all required fields; a PostUpdate model has all optional fields (for PATCH); a PostResponse model has computed/generated fields like id, created_at, and view_count. Sharing a single model for all purposes forces compromises that result in either over-validation on reads or under-validation on writes.published: bool will accept "false" (the string) and coerce it to True in strict mode — in lax mode (the default) it converts "false" to False. Understand what coercions Pydantic applies in lax mode for each type, and use model_config = ConfigDict(strict=True) if you need exact type matching without coercion.Field() — Metadata and Constraints
from pydantic import BaseModel, Field
from typing import Annotated
class PostCreate(BaseModel):
title: str = Field(..., min_length=3, max_length=200,
description="The post title")
body: str = Field(..., min_length=10,
description="Main post content (Markdown)")
tags: list[str] = Field(default=[], max_length=10,
description="Up to 10 tags")
view_count: int = Field(default=0, ge=0, description="Cannot be negative")
rating: float = Field(default=None, ge=0.0, le=5.0,
description="Rating 0–5 or null")
# Field constraints for common types:
# str: min_length, max_length, pattern (regex)
# int: ge (>=), gt (>), le (<=), lt (<), multiple_of
# float: ge, gt, le, lt
# list: min_length, max_length (of the list itself)
# ── Annotated syntax — reusable constraints ────────────────────────────────────
from typing import Annotated
ShortTitle = Annotated[str, Field(min_length=3, max_length=200)]
PositiveInt = Annotated[int, Field(ge=1)]
Rating = Annotated[float | None, Field(ge=0.0, le=5.0, default=None)]
class PostCreate2(BaseModel):
title: ShortTitle
view_count: PositiveInt = 0
rating: Rating
Validators — @field_validator and @model_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Self
class PostCreate(BaseModel):
title: str
body: str
slug: str | None = None
start_date: str | None = None
end_date: str | None = None
@field_validator("title")
@classmethod
def validate_title(cls, v: str) -> str:
"""Strip whitespace and ensure minimum length after cleaning."""
v = v.strip()
if len(v) < 3:
raise ValueError("Title must be at least 3 characters after stripping")
return v
@field_validator("slug", mode="before") # mode="before" runs before type coercion
@classmethod
def auto_generate_slug(cls, v: str | None, info) -> str | None:
if v:
return v.lower().replace(" ", "-")
# If slug not provided, generate from title (if title already validated)
return v
@model_validator(mode="after") # runs after all field validation
def validate_date_range(self) -> Self:
if self.start_date and self.end_date:
if self.start_date > self.end_date:
raise ValueError("start_date must be before end_date")
return self
@model_validator(mode="before") # runs before field validation, on raw data
@classmethod
def validate_not_empty(cls, data: dict) -> dict:
if not data.get("title") and not data.get("body"):
raise ValueError("At least title or body must be provided")
return data
Nested Models and Config
from pydantic import BaseModel, ConfigDict
class AuthorResponse(BaseModel):
id: int
name: str
email: str
class PostResponse(BaseModel):
model_config = ConfigDict(
from_attributes=True, # allow building from SQLAlchemy model attributes
populate_by_name=True, # allow both field name and alias
str_strip_whitespace=True, # auto-strip strings
)
id: int
title: str
body: str
author: AuthorResponse # nested model
tags: list[str] = []
published: bool
created_at: datetime
# Build from a SQLAlchemy ORM object
# post = db.query(Post).first()
# response = PostResponse.model_validate(post) # reads attributes via from_attributes
# Build from dict
data = {
"id": 1, "title": "Hello", "body": "World",
"author": {"id": 42, "name": "Alice", "email": "alice@example.com"},
"published": True,
"created_at": "2025-08-06T14:30:00"
}
response = PostResponse.model_validate(data)
Common Mistakes
Mistake 1 — Forgetting @classmethod on field_validator
❌ Wrong — validator is not a classmethod:
@field_validator("title")
def validate_title(cls, v): # missing @classmethod decorator!
return v.strip()
✅ Correct:
@field_validator("title")
@classmethod
def validate_title(cls, v: str) -> str: # ✓
return v.strip()
Mistake 2 — Using model_dump() for PATCH without exclude_none
❌ Wrong — None fields overwrite existing data:
patch = PostUpdate(title="New") # body=None, published=None
db_post.update(patch.model_dump()) # sets body=None in database!
✅ Correct:
db_post.update(patch.model_dump(exclude_none=True)) # only {title: "New"} ✓
Mistake 3 — Forgetting from_attributes=True for ORM object validation
❌ Wrong — validation fails when building from SQLAlchemy object:
class PostResponse(BaseModel):
id: int; title: str # no from_attributes config
PostResponse.model_validate(sqlalchemy_post) # ValidationError!
✅ Correct:
model_config = ConfigDict(from_attributes=True) # ✓ reads ORM attributes
Quick Reference
| Task | Code |
|---|---|
| Define model | class M(BaseModel): field: type = default |
| Validate from dict | M.model_validate(data) |
| Validate from JSON | M.model_validate_json(json_str) |
| To dict | m.model_dump(exclude_none=True) |
| To JSON string | m.model_dump_json() |
| Field constraints | Field(min_length=3, ge=0) |
| Field validator | @field_validator("f") @classmethod def v(cls, v): ... |
| Model validator | @model_validator(mode="after") def v(self): ... |
| ORM mode | model_config = ConfigDict(from_attributes=True) |