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.
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": "..."}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("/")becomesGET /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.
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
idandcreated_atgo 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 #
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— lengthpattern— regexge,le,gt,lt— numeric rangedefault,default_factory— default valuesdescription,example— Swagger documentation
Custom validators #
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 #
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 parsedresponse_model=TodoOut— responses are serialized through this schema. Extra fields are filtered out automaticallystatus_code=201— explicit response codepayload.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.
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)]@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:
- Shared logic in one place — auth, permissions, DB sessions, pagination, etc. live once and routes just receive them
- Swappable in tests —
app.dependency_overrides[verify_token] = mock_verifyis 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 #
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 userget_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 #
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 #
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@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 #
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.
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 #
- Make a
todosrouter in a separate file withAPIRouterand register it withapp.include_router(...). Confirm in/docsthat the routes are grouped under thetodostag and that the prefix is applied automatically. - Write the three schemas
TodoCreate/TodoUpdate/TodoOutand implement the POST / PATCH / GET routes. Confirm that themodel_dump(exclude_unset=True)+model_copy(update=...)pattern works in PATCH (a request that only sendsdescriptiondoes not overwritetitle). - Build a
Paginationdependency (dataclass withskip,limit) withDependsand haveGET /todosreceive it as query parameters. Confirm how the pattern of passing a class to emptyDepends()parentheses appears in Swagger UI.
In one line:
APIRouterfor prefix + tags + module separation. Pydantic v2’s BaseModel + Field validation,EmailStr/HttpUrl,@field_validator/@model_validator. Splitting input / output schemas gives security + clarity.response_modelfor response serialization, PATCH usesmodel_dump(exclude_unset=True)+model_copy.Dependsfactors out shared logic like auth / DB / pagination, automatic dependency graph + per-request caching.Page[T]generic responses,@app.exception_handlermaps 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.