Modern Python in Practice #2: Routing, Pydantic Models, and Dependency Injection
In #1 Getting Started and Setup we wrote all routes in a single app/main.py file. This post expands that into a scalable structure. Three tools sit at the core:
- APIRouter — split routes by module
- Pydantic models — input/output schemas, automatic validation
Depends— extract shared logic (DB sessions, authenticated users, etc.) cleanly with dependency injection
APIRouter — modularizing routes #
As routes grow, packing them all into one file gets awkward. 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 Intermediate #1 we said dataclass doesn’t fit places where strong validation is needed. Pydantic fills that role.
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 #3.
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.
Using Pydantic in routes #
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException
from app.schemas.todo import TodoCreate, TodoUpdate, TodoOut
from app.schemas.common import Page
router = APIRouter(prefix="/todos", tags=["todos"])
# Temporary in-memory store (swapped for a DB in #3)
_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 duplicating 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 #4
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 is 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 #6
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 Intermediate #2 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 where they are converted to HTTP responses. Routes stay cleaner.
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, restrict it to an explicit list of domains.
Recap #
What this post nailed down:
APIRouter— per-module routes withprefix+tags, mounted viaapp.include_router- Pydantic v2 —
BaseModel,Fieldvalidators,EmailStr/HttpUrl,@field_validator - Separate input and output schemas — security plus clarity
model_config = {"from_attributes": True}— read directly from ORM objectsresponse_model=for serialization and filtering- For PATCH:
model_dump(exclude_unset=True)+model_copy(update=...) Depends— extract shared logic like auth, DB sessions, and pagination- Automatic dependency-graph resolution with per-request caching
- Generic response models like
Page[T] @app.exception_handlerto map domain exceptions to HTTP responses- CORS middleware
In the next post (#3 Connecting a DB — SQLAlchemy 2.x + Alembic) we replace this in-memory dict with a real database. SQLAlchemy 2.x’s new style, async sessions, and Alembic migrations.