Staff Prep 11: API Design — Pagination, Filtering & Error Handling at Scale
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.
-- 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.
-- 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.
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
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
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):
- A client requests page 5000 of a 10M-row table using OFFSET. Explain what Postgres does internally and why it is slow.
- 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?
- Why do you include the
idin the cursor alongsidecreated_at? What breaks without it? - 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? - 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.