Week 16: API Design at Scale — Versioning, Pagination & OpenAPI

Week 3 taught you to build a correct endpoint. It didn't teach you what happens six months later when that endpoint needs to change shape and other clients already depend on the old one. This week is about designing an API surface that can evolve: versioning that doesn't break existing clients, pagination that scales past a few thousand rows, and getting real value out of FastAPI's automatic OpenAPI docs instead of leaving them at their defaults.

Module 13 of 22 Week 16 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Choose and implement an API versioning strategy without breaking existing clients
  • Implement cursor-based pagination and consistent filtering/sorting conventions
  • Customize FastAPI's generated OpenAPI docs and apply per-client rate limiting

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.

routers/tasks_v1.py & tasks_v2.py, mounted separately
# 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:

signaling deprecation on v1
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))
A breaking change is defined by the consumer, not the producer

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.

offset pagination — the simple default
@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":

cursor-based pagination
@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:

a well-documented endpoint
@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:

per-client rate limiting with slowapi + Redis
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)):
    ...
Always back a rate limiter with Redis, never in-process memory

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

Hands-on

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:

  1. Create a v2 router that changes an existing endpoint's response shape, while keeping v1 working unmodified for existing callers; add Deprecation/Sunset headers to the v1 response.
  2. 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).
  3. Add descriptions, examples, and documented error responses to at least three endpoints, and confirm the Swagger UI at /docs reflects them accurately.
  4. Add a per-client rate limit backed by Redis, and confirm a client exceeding it receives a 429.
Hint

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?

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?

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?

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.