Pydantic Models — Validation, Serialisation and Configuration

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
Note: Pydantic v2’s 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.
Tip: Use separate Pydantic models for creating, updating, and reading a resource — the “CQRS-lite” pattern. A 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.
Warning: Pydantic’s type coercion is powerful but can mask bugs. 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)

🧠 Test Yourself

A PATCH endpoint updates a post. The client sends {"title": "New Title"}. Your PostUpdate model has all fields as Optional with None defaults. You call post.update(patch.model_dump()). What is the bug?