1. API Versioning & Deprecation
The moment an API has consumers you don't fully control, a "breaking change" isn't
just a code change — it's a change that breaks someone else's running application.
FastAPI's APIRouter makes URI versioning straightforward: mount
differently-versioned routers under different prefixes, each with its own response
models.
# main.py
from routers import tasks_v1, tasks_v2
app.include_router(tasks_v1.router, prefix="/api/v1/tasks", tags=["tasks-v1"])
app.include_router(tasks_v2.router, prefix="/api/v2/tasks", tags=["tasks-v2"])
Both routers can share the same service layer and database models underneath — only the request/response Pydantic models and route handlers need to differ between versions, so v2's new shape doesn't require duplicating business logic.
Whichever strategy you pick, deprecation needs a signal, not a surprise
removal. A Deprecation and Sunset HTTP header
(RFC 8594) on the old version's responses gives clients a machine-readable warning
and a concrete date, well before the endpoint actually disappears:
from fastapi import Response
@router.get("/{task_id}")
def get_task(task_id: int, response: Response) -> TaskResponseV1:
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = "Wed, 01 Apr 2026 00:00:00 GMT"
response.headers["Link"] = '</api/v2/tasks>; rel="successor-version"'
return to_response_v1(task_service.get_task(task_id))
Adding a new optional field to a Pydantic response model is safe; renaming an existing field, changing its type, or making an optional field required are all breaking, even if the diff looks small. Before shipping any API change, ask specifically whether an existing client's current parsing code would still work unmodified — that's the real test, not whether the change feels minor to write.
2. Pagination, Filtering & Sorting
LIMIT/OFFSET pagination is the simplest default and fine for
most admin screens and small datasets. Its real weakness at scale: a large
OFFSET forces the database to scan and discard every row before it, so
deep pages against a large table get progressively slower — and rows inserted or
deleted between page requests can shift offsets, silently skipping or duplicating
rows.
@router.get("/tasks")
async def list_tasks(
db: AsyncSession = Depends(get_db),
limit: int = 20,
offset: int = 0,
) -> list[TaskResponse]:
stmt = select(Task).order_by(Task.created_at.desc()).limit(limit).offset(offset)
result = await db.execute(stmt)
return [to_response(t) for t in result.scalars().all()]
Cursor-based (keyset) pagination solves both problems by paginating from a stable reference point instead of a row count — "give me the next 20 tasks created before this specific timestamp," not "give me rows 100 through 120":
@router.get("/tasks")
async def list_tasks(
db: AsyncSession = Depends(get_db),
cursor: datetime | None = None,
limit: int = 20,
) -> TaskPage:
stmt = select(Task).order_by(Task.created_at.desc()).limit(limit)
if cursor is not None:
stmt = stmt.where(Task.created_at < cursor)
result = await db.execute(stmt)
tasks = result.scalars().all()
next_cursor = tasks[-1].created_at if tasks else None
return TaskPage(items=[to_response(t) for t in tasks], next_cursor=next_cursor)
This is why most large-scale public APIs (Stripe, GitHub, Slack) use cursors rather than offsets — the query stays roughly constant-time regardless of how deep into the dataset a client has paginated, and rows inserted during pagination don't shift already-fetched results.
Standardize filtering and sorting the same way across every list endpoint —
?sort=created_at:desc&status=open, applied identically everywhere —
rather than each endpoint inventing its own query parameter shape. A consumer who's
learned one endpoint's filtering syntax should be able to guess every other
endpoint's correctly.
3. OpenAPI Documentation & Rate Limiting
FastAPI generates OpenAPI docs automatically from your route signatures and Pydantic models, but the defaults are bare — no descriptions, no example values, no documented error responses. Customizing them directly in the route decorator produces documentation a consumer can actually rely on:
@router.get(
"/{task_id}",
response_model=TaskResponse,
summary="Fetch a task by ID",
responses={
404: {"description": "No task with that ID"},
},
)
async def get_task(
task_id: int = Path(..., description="The task's unique ID", examples=[42]),
) -> TaskResponse:
...
With this on the classpath, the browsable Swagger UI at /docs (and
ReDoc at /redoc) reflects real descriptions, examples, and documented
error cases — generated from exactly the same source of truth as the running code, so
it can't drift out of sync the way a hand-maintained separate spec can.
Once an API has external, potentially untrusted consumers,
rate limiting protects it from a single client — buggy or
malicious — consuming a disproportionate share of capacity.
slowapi (a FastAPI-friendly port of Flask-Limiter) implements this as a
dependency:
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address, storage_uri="redis://localhost:6379")
app.state.limiter = limiter
@router.get("/tasks")
@limiter.limit("100/minute")
async def list_tasks(request: Request, db: AsyncSession = Depends(get_db)):
...
An in-memory rate limit store resets independently on every worker process spawned by Gunicorn (Week 13), and each replica behind a load balancer tracks its own separate count — a client could get far more than the intended limit split across workers and replicas. Redis (Week 11) gives every process a single, shared view of each client's usage, which is what actually enforces the limit you configured.
4. Hands-on Exercise
Version an endpoint, add cursor pagination, and document and rate-limit the API
Apply all three practices to the task service from earlier weeks.
Requirements:
- Create a
v2router that changes an existing endpoint's response shape, while keepingv1working unmodified for existing callers; addDeprecation/Sunsetheaders to thev1response. - Replace offset pagination on your task list endpoint with cursor-based pagination, and confirm query performance stays flat when paginating deep into a table seeded with several thousand rows (compare against the offset version at a high page offset).
- Add descriptions, examples, and documented error responses to at least three endpoints, and confirm the Swagger UI at
/docsreflects them accurately. - Add a per-client rate limit backed by Redis, and confirm a client exceeding it receives a
429.
Seed at least 10,000 test rows before comparing offset vs. cursor pagination performance — the difference is invisible on a table with 50 rows and becomes obvious once OFFSET actually has something expensive to skip past.
5. Knowledge Check
Three quick questions. Expand each to check your answer.
Q1
Why can two versioned routers safely share the same service layer and database models?
Why can two versioned routers safely share the same service layer and database models?
The business logic and persistence layer don't change between API versions — only the request/response shape presented to clients differs. Keeping the underlying service and models shared avoids duplicating logic across versions; only the Pydantic models and thin route handlers that translate between the shared internal representation and each version's public contract need to differ.
Q2
Why does cursor-based pagination stay roughly constant-time while offset pagination gets slower on deeper pages?
Why does cursor-based pagination stay roughly constant-time while offset pagination gets slower on deeper pages?
An OFFSET query still has to scan and discard every row before the requested offset, so the cost grows with how deep into the table the client has paginated. A cursor query filters directly on an indexed column with a WHERE clause, so the database can jump straight to the right starting point using the index regardless of how many rows came before it.
Q3
Why does an in-memory rate limit store break down once a FastAPI service runs multiple Gunicorn workers or replicas?
Why does an in-memory rate limit store break down once a FastAPI service runs multiple Gunicorn workers or replicas?
Each worker process (and each replica) holds its own independent in-memory count, with no shared state between them. A client's requests get distributed across workers and replicas, so their actual consumption is split across several independently-tracked limits instead of one shared one — effectively multiplying their true rate limit, unless the count lives in a shared store like Redis instead.