Staff Prep 11: API Design — Pagination, Filtering & Error Handling at Scale
ArchitectureStaff

Staff Prep 11: API Design — Pagination, Filtering & Error Handling at Scale

April 4, 20269 min readPART 09 / 18

Back to Part 10: Background Processing. Pagination looks trivial until you hit page 500 of a 10-million-row table and your query takes 30 seconds. Or until concurrent inserts cause rows to skip or duplicate across pages. Cursor pagination solves both problems. This is the API design pattern every Staff engineer must be able to implement and explain.

Why OFFSET pagination breaks at scale

OFFSET instructs Postgres to skip N rows before returning M rows. To skip N rows, Postgres must read and discard them. At OFFSET 100,000 with LIMIT 20, Postgres reads 100,020 rows and returns 20.

sql
-- Page 1: fast (reads 20 rows)
SELECT * FROM events ORDER BY created_at DESC LIMIT 20 OFFSET 0;

-- Page 100: reads 2,020 rows, returns 20
SELECT * FROM events ORDER BY created_at DESC LIMIT 20 OFFSET 2000;

-- Page 10,000: reads 200,020 rows, returns 20 — 30+ seconds
SELECT * FROM events ORDER BY created_at DESC LIMIT 20 OFFSET 200000;

-- Second problem: concurrent inserts cause row skips
-- User fetches page 1 (rows 1-20)
-- Another user inserts a new row at position 1
-- User fetches page 2 (rows 21-40 + offset) → row 21 is now at position 22 → row 20 shown twice
-- Or rows get skipped entirely

Cursor pagination: the correct implementation

Instead of "skip N rows", cursor pagination uses a WHERE clause: "give me rows after this specific row". Stable under concurrent inserts. O(log n) regardless of page number.

sql
-- Cursor pagination: always efficient (uses the index)
-- First page
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Last row: id=5000, created_at='2026-04-03T12:00:00Z'

-- Second page: "give me rows before the cursor"
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-04-03T12:00:00Z', 5000)
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- This uses the index (created_at DESC, id DESC) efficiently
-- No matter which page number you are on, it reads exactly LIMIT rows

-- Why include id in the cursor?
-- Multiple posts can have identical created_at timestamps
-- The id makes the cursor unique and deterministic

Cursor encoding: opaque to the client

Expose cursors as opaque base64 strings. The client should not parse or construct cursors — that creates tight coupling to your internal data model.

python
import base64
import json
from fastapi import FastAPI, Query
from pydantic import BaseModel
from typing import Optional

def encode_cursor(created_at: str, id: int) -> str:
    payload = json.dumps({"created_at": created_at, "id": id})
    return base64.b64encode(payload.encode()).decode()

def decode_cursor(cursor: str) -> tuple[str, int]:
    payload = json.loads(base64.b64decode(cursor.encode()).decode())
    return payload["created_at"], payload["id"]

class PaginatedResponse(BaseModel):
    items: list
    next_cursor: Optional[str]
    has_more: bool

@app.get("/posts")
async def list_posts(
    cursor: Optional[str] = Query(None),
    limit: int = Query(20, ge=1, le=100),
    db: AsyncSession = Depends(get_db),
):
    query_limit = limit + 1  # fetch one extra to determine has_more

    if cursor:
        created_at, last_id = decode_cursor(cursor)
        rows = await db.execute(
            """SELECT id, title, created_at FROM posts
               WHERE (created_at, id) < ($1::timestamptz, $2)
               ORDER BY created_at DESC, id DESC LIMIT $3""",
            created_at, last_id, query_limit
        )
    else:
        rows = await db.execute(
            """SELECT id, title, created_at FROM posts
               ORDER BY created_at DESC, id DESC LIMIT $1""",
            query_limit
        )

    posts = rows.fetchall()
    has_more = len(posts) > limit
    items = posts[:limit]  # trim the extra row

    next_cursor = None
    if has_more and items:
        last = items[-1]
        next_cursor = encode_cursor(last["created_at"].isoformat(), last["id"])

    return PaginatedResponse(items=items, next_cursor=next_cursor, has_more=has_more)

Filtering: clean API design

python
from fastapi import Query
from enum import Enum
from typing import Optional
from datetime import date

class OrderStatus(str, Enum):
    pending = "pending"
    processing = "processing"
    shipped = "shipped"
    delivered = "delivered"

@app.get("/orders")
async def list_orders(
    status: Optional[OrderStatus] = Query(None),
    user_id: Optional[int] = Query(None),
    created_after: Optional[date] = Query(None),
    created_before: Optional[date] = Query(None),
    min_amount: Optional[float] = Query(None, ge=0),
    cursor: Optional[str] = Query(None),
    limit: int = Query(20, ge=1, le=100),
    db: AsyncSession = Depends(get_db),
):
    # Build dynamic WHERE clause safely
    conditions = []
    params = []

    if status:
        conditions.append(f"status = ${len(params)+1}")
        params.append(status.value)

    if user_id:
        conditions.append(f"user_id = ${len(params)+1}")
        params.append(user_id)

    if created_after:
        conditions.append(f"created_at >= ${len(params)+1}")
        params.append(created_after)

    if cursor:
        created_at, last_id = decode_cursor(cursor)
        conditions.append(f"(created_at, id) < (${len(params)+1}::timestamptz, ${len(params)+2})")
        params.extend([created_at, last_id])

    where = "WHERE " + " AND ".join(conditions) if conditions else ""

    sql = f"""SELECT id, user_id, amount, status, created_at
              FROM orders {where}
              ORDER BY created_at DESC, id DESC
              LIMIT ${len(params)+1}"""
    params.append(limit + 1)

    rows = await db.execute(sql, *params)
    # ... rest of pagination logic

Error response design

python
from fastapi import Request
from fastapi.responses import JSONResponse
from pydantic import ValidationError
import uuid

# Consistent error format
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc):
    return JSONResponse(
        status_code=422,
        content={
            "error": "validation_failed",
            "message": "Request validation failed",
            "details": [
                {"field": ".".join(str(loc) for loc in e["loc"]), "message": e["msg"]}
                for e in exc.errors()
            ],
            "request_id": str(uuid.uuid4()),
        }
    )

# Clients get structured, actionable errors:
# {
#   "error": "validation_failed",
#   "message": "Request validation failed",
#   "details": [
#     {"field": "body.amount", "message": "ensure this value is greater than 0"}
#   ],
#   "request_id": "abc-123"
# }

Quiz: test your understanding

Before moving on, answer these in your head (or out loud):

  1. A client requests page 5000 of a 10M-row table using OFFSET. Explain what Postgres does internally and why it is slow.
  2. With OFFSET pagination, a user is scrolling a feed. Between page 1 and page 2, another user posts. What happens to the user's pagination experience? Does cursor pagination fix this?
  3. Why do you include the id in the cursor alongside created_at? What breaks without it?
  4. You are designing a cursor for a list sorted by score DESC, id DESC. A user is on page 3. The last item has score=95, id=2847. What is the next-page cursor query?
  5. How do you handle the case where a client sends a malformed or tampered cursor? What should the API return?

Next up — Part 12: Rate Limiting & Throttling. Token bucket vs sliding window, per-user vs global limits, and atomic Redis pipelines.

← PREV
Staff Prep 10: Background Processing — In-Process vs Celery vs Workers
← All Architecture Posts