Modern Python in Practice #2: Routing, Pydantic Models, and Dependency Injection

7 min read

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.

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 Intermediate #1 we said dataclass doesn’t fit places where strong validation is needed. Pydantic fills that role.

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 #3.

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.

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
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 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 duplicating 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 #4
    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 is 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 #6

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 Intermediate #2 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 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.

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, restrict it to an explicit list of domains.

Recap #

What this post nailed down:

  • APIRouter — per-module routes with prefix + tags, mounted via app.include_router
  • Pydantic v2 — BaseModel, Field validators, EmailStr/HttpUrl, @field_validator
  • Separate input and output schemas — security plus clarity
  • model_config = {"from_attributes": True} — read directly from ORM objects
  • response_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_handler to 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.

X