Contents
23 Chapter

Routing, Pydantic models, and dependency injection

Splitting routes with APIRouter, defining input/output schemas with Pydantic v2, and factoring shared logic out with Depends.

In Chapter 22 FastAPI setup and getting started we wrote routes in a single app/main.py file. This chapter turns that into a scalable structure. Three tools sit at the core:

  • APIRouter — split routes by module
  • Pydantic models — input / output schemas, automatic validation
  • Depends — unravel shared logic (DB sessions, authenticated users, etc.) with dependency injection

This chapter only covers the surface of Pydantic. The depth — validation / serialization / discriminated union — gets the full treatment in the next chapter, Pydantic v2 in depth.

APIRouter — modularizing routes #

As routes grow, keeping them all in one file becomes hard to maintain. APIRouter lets you split them by module.

app/api/todos.py
from fastapi import APIRouter

router = APIRouter(prefix="/todos", tags=["todos"])

@router.get("/")
def list_todos():
    return [{"id": 1, "title": "Learn FastAPI"}]

@router.get("/{todo_id}")
def get_todo(todo_id: int):
    return {"id": todo_id, "title": "..."}
app/main.py
from fastapi import FastAPI
from app.api import todos

app = FastAPI(title="Todo API")
app.include_router(todos.router)

Three benefits:

  • prefix="/todos" — automatically prepended to every route. @router.get("/") becomes GET /todos/
  • tags=["todos"] — grouping in the Swagger UI
  • Module separation — easy to split by domain like auth.py, users.py

Pydantic v2 models — what schemas really are #

Pydantic is a library that combines dataclass + validation + serialization. In Chapter 8 dataclass we said dataclass doesn’t fit cases where strong validation is needed. Pydantic is the tool for that boundary.

app/schemas/todo.py
from datetime import datetime
from pydantic import BaseModel, Field

class TodoCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    description: str | None = None

class TodoUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=200)
    description: str | None = None
    done: bool | None = None

class TodoOut(BaseModel):
    id: int
    title: str
    description: str | None
    done: bool
    created_at: datetime

    model_config = {"from_attributes": True}

Key patterns:

  • Separate input schemas (TodoCreate, TodoUpdate) from output schemas (TodoOut)
  • Inputs are what clients can send; outputs are what the server exposes
  • Server-generated fields like id and created_at go only in the output
  • Fields that must not be exposed (like passwords) are absent from the output

This separation gives you security and clarity at the same time.

from_attributes=True — read directly from ORM objects #

The model_config = {"from_attributes": True} option lets you read data via attribute access from objects like SQLAlchemy models (the old v1 orm_mode). Details in Chapter 25 Connecting a DB — SQLAlchemy 2.x + Alembic.

Pydantic v2 built-in validation #

Common validators
from pydantic import BaseModel, EmailStr, HttpUrl, Field
from datetime import datetime

class UserCreate(BaseModel):
    email: EmailStr                                       # email format
    username: str = Field(min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_]+$")
    password: str = Field(min_length=8)
    website: HttpUrl | None = None                        # URL
    birth_year: int = Field(ge=1900, le=2030)             # range
    bio: str = Field(max_length=500, default="")

EmailStr and HttpUrl need an extra package. With the [standard] option it’s already included. Field() options:

  • min_length, max_length — length
  • pattern — regex
  • ge, le, gt, lt — numeric range
  • default, default_factory — default values
  • description, example — Swagger documentation

Custom validators #

@field_validator / @model_validator
from pydantic import BaseModel, field_validator, model_validator

class TodoCreate(BaseModel):
    title: str
    priority: int = 1

    @field_validator("title")
    @classmethod
    def title_no_html(cls, v: str) -> str:
        if "<" in v or ">" in v:
            raise ValueError("HTML tags are not allowed")
        return v.strip()

    @model_validator(mode="after")
    def check_priority_with_title(self):
        if self.priority > 5 and "urgent" not in self.title:
            raise ValueError("priority above 5 requires 'urgent' in the title")
        return self
  • @field_validator("name") — validate / transform a single field
  • @model_validator(mode="after") — model-level validation after every field is filled

When validation fails, FastAPI automatically returns 422 Unprocessable Entity. Deeper usage of validation / serialization is in Chapter 24 Pydantic v2 in depth.

Using Pydantic in routes #

app/api/todos.py
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException
from app.schemas.todo import TodoCreate, TodoUpdate, TodoOut

router = APIRouter(prefix="/todos", tags=["todos"])

# Temporary in-memory store (swapped for a DB in Chapter 25)
_db: dict[int, TodoOut] = {}
_next_id = 1

@router.post("/", response_model=TodoOut, status_code=201)
def create_todo(payload: TodoCreate) -> TodoOut:
    global _next_id
    todo = TodoOut(
        id=_next_id,
        title=payload.title,
        description=payload.description,
        done=False,
        created_at=datetime.now(timezone.utc),
    )
    _db[_next_id] = todo
    _next_id += 1
    return todo

@router.get("/{todo_id}", response_model=TodoOut)
def read_todo(todo_id: int) -> TodoOut:
    if todo_id not in _db:
        raise HTTPException(status_code=404, detail="Todo not found")
    return _db[todo_id]

@router.patch("/{todo_id}", response_model=TodoOut)
def update_todo(todo_id: int, payload: TodoUpdate) -> TodoOut:
    if todo_id not in _db:
        raise HTTPException(status_code=404, detail="Todo not found")
    current = _db[todo_id]
    update_data = payload.model_dump(exclude_unset=True)
    updated = current.model_copy(update=update_data)
    _db[todo_id] = updated
    return updated

@router.delete("/{todo_id}", status_code=204)
def delete_todo(todo_id: int) -> None:
    if todo_id not in _db:
        raise HTTPException(status_code=404, detail="Todo not found")
    del _db[todo_id]

Checkpoints:

  • payload: TodoCreate — POST / PATCH bodies are automatically validated and parsed
  • response_model=TodoOut — responses are serialized through this schema. Extra fields are filtered out automatically
  • status_code=201 — explicit response code
  • payload.model_dump(exclude_unset=True) — pull only the fields the client actually set (the heart of PATCH)

exclude_unset — the essence of PATCH #

If a PATCH request only sends title, you must not treat description=None as the new value (which would overwrite it). The meaning of PATCH is leave unspecified fields alone, and exclude_unset=True is what unlocks that.

Partial updates with model_copy #

current.model_copy(update={...}) creates a new instance with only some fields updated. An immutable-object pattern.

Dependency injection — Depends #

To avoid repeating the same code across routes, factor it out as a dependency.

app/api/deps.py
from typing import Annotated
from fastapi import Depends, HTTPException, Header

def verify_token(authorization: Annotated[str | None, Header()] = None) -> dict:
    if authorization is None:
        raise HTTPException(401, "Authorization header required")
    if not authorization.startswith("Bearer "):
        raise HTTPException(401, "Invalid auth scheme")
    token = authorization.split(" ", 1)[1]
    # Real verification comes in Chapter 26
    return {"sub": "user123"}

CurrentUser = Annotated[dict, Depends(verify_token)]
Use in a route
@router.post("/", response_model=TodoOut)
def create_todo(payload: TodoCreate, user: CurrentUser) -> TodoOut:
    print("user:", user["sub"])
    ...

Whatever the function returns gets injected wherever Depends(fn) appears. Two benefits:

  1. Shared logic in one place — auth, permissions, DB sessions, pagination, etc. live once and routes just receive them
  2. Swappable in testsapp.dependency_overrides[verify_token] = mock_verify is a one-line swap. Details in Chapter 28 Testing and deploy

This pattern is the result of combining the class-decorator pattern from Chapter 12 Decorator patterns with the Annotated from Chapter 20 Advanced typing.

Dependencies can have dependencies #

Nested dependencies
def get_db_session() -> Iterator[Session]:
    with SessionLocal() as session:
        yield session

DBSession = Annotated[Session, Depends(get_db_session)]

def get_current_user(token: Annotated[str, Depends(...)], db: DBSession) -> User:
    user = db.query(User).filter(User.id == token).first()
    if not user:
        raise HTTPException(401)
    return user

get_current_user depends on get_db_session. FastAPI resolves the dependency graph automatically and runs each dependency only once per request. Per-request caching is automatic too.

Class-style dependencies — dependencies with parameters #

page params class
from dataclasses import dataclass

@dataclass
class Pagination:
    skip: int = 0
    limit: int = 100

PaginationDep = Annotated[Pagination, Depends()]

@router.get("/")
def list_todos(p: PaginationDep) -> list[TodoOut]:
    return list(_db.values())[p.skip : p.skip + p.limit]

Pass a class to an empty Depends() and its fields are automatically converted into query parameters. Clean for repeated parameters like pagination across many routes.

Standard response models — pagination, errors #

app/schemas/common.py
from typing import Generic, TypeVar
from pydantic import BaseModel

T = TypeVar("T")

class Page(BaseModel, Generic[T]):
    items: list[T]
    total: int
    skip: int
    limit: int

class ErrorResponse(BaseModel):
    detail: str
    code: str | None = None
In a route
@router.get("/", response_model=Page[TodoOut])
def list_todos(p: PaginationDep) -> Page[TodoOut]:
    items = list(_db.values())
    return Page(
        items=items[p.skip : p.skip + p.limit],
        total=len(items),
        skip=p.skip,
        limit=p.limit,
    )

A generic response like Page[TodoOut] keeps type safety while shipping metadata alongside the data. The Generic from Chapter 9 Typing in earnest works exactly the same here.

Exception handlers — consistent error shape #

app/main.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

class NotFoundError(Exception):
    def __init__(self, resource: str, key: str):
        self.resource = resource
        self.key = key

app = FastAPI()

@app.exception_handler(NotFoundError)
async def not_found_handler(request: Request, exc: NotFoundError):
    return JSONResponse(
        status_code=404,
        content={"detail": f"{exc.resource} {exc.key} not found", "code": "not_found"},
    )

In a route:

@router.get("/{todo_id}")
def read_todo(todo_id: int) -> TodoOut:
    if todo_id not in _db:
        raise NotFoundError("Todo", str(todo_id))
    return _db[todo_id]

Define domain exceptions and centralize the place where they become HTTP responses. Routes get cleaner. The custom-exception-hierarchy pattern from Chapter 6 Errors and exception handling carries over directly.

CORS — running alongside a frontend #

Browser apps calling an API on another domain need CORS configured.

CORS middleware
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000", "https://myapp.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Use allow_origins=["*"] only during development. In production, narrow it to an explicit list of domains.

Exercises #

  1. Make a todos router in a separate file with APIRouter and register it with app.include_router(...). Confirm in /docs that the routes are grouped under the todos tag and that the prefix is applied automatically.
  2. Write the three schemas TodoCreate / TodoUpdate / TodoOut and implement the POST / PATCH / GET routes. Confirm that the model_dump(exclude_unset=True) + model_copy(update=...) pattern works in PATCH (a request that only sends description does not overwrite title).
  3. Build a Pagination dependency (dataclass with skip, limit) with Depends and have GET /todos receive it as query parameters. Confirm how the pattern of passing a class to empty Depends() parentheses appears in Swagger UI.

In one line: APIRouter for prefix + tags + module separation. Pydantic v2’s BaseModel + Field validation, EmailStr / HttpUrl, @field_validator / @model_validator. Splitting input / output schemas gives security + clarity. response_model for response serialization, PATCH uses model_dump(exclude_unset=True) + model_copy. Depends factors out shared logic like auth / DB / pagination, automatic dependency graph + per-request caching. Page[T] generic responses, @app.exception_handler maps domain exceptions → HTTP, CORS middleware.

Next chapter #

Next, the ★new Chapter 24 Pydantic v2 in depth extends this chapter’s surface look at Pydantic into depth. The v1 → v2 changes, validation lifecycle, custom serialization, JSON Schema generation — the core of FastAPI.

X