REST APIs in Depth · Representations, Collections and Queries

Pagination in Depth

Offset, cursor and keyset pagination, totals, and page metadata.

Every collection must be paginated with a capped page size. Offset pagination (?page=3&per_page=50) supports jumping to pages but slows on deep pages and shifts when data changes. Cursor/keyset pagination (?cursor=…) is fast and stable for feeds, timelines and large tables. Return either a next_cursor in the body or RFC 8288 Link headers. Totals are expensive on big tables — offer them optionally (?include_total=true) or approximate them.

Pagination strategies

Criterion Offset/limit Cursor (opaque) Keyset (explicit)
Deep page performance Degrades (scans skipped rows) Constant Constant
Stable under inserts No (duplicates/skips) Yes Yes
Jump to page N Yes No No
Arbitrary sort Easy Needs sort key in cursor Needs index per sort
Client complexity Low Low (opaque token) Medium (passes last values)
Best for Small admin tables Feeds, public APIs Internal/export jobs
@app.get("/orders")
async def list_orders(limit: int = Query(20, ge=1, le=100), cursor: str | None = None, db=Depends(get_db)):
    stmt = select(Order).order_by(Order.created_at.desc(), Order.id.desc()).limit(limit + 1)
    if cursor:
        ts, oid = decode_cursor(cursor)
        stmt = stmt.where(tuple_(Order.created_at, Order.id) < (ts, oid))
    rows = list(await db.scalars(stmt))
    nxt = encode_cursor(rows[limit - 1].created_at, rows[limit - 1].id) if len(rows) > limit else None
    return {"items": rows[:limit], "next_cursor": nxt}

Common pitfalls

  • Unbounded page sizes.
  • Sorting by non-unique columns without a tiebreaker.
  • COUNT(*) on every request for million-row tables.

Key points

  • Always paginate with a max size
  • Cursors for large or changing data
  • Totals optional or approximate

Further reading: RFC 8288: Web Linking

Practice questions

Why do cursor tokens usually encode the sort key and a unique id?

Answer

To resume exactly after the last item even when many rows share the same sort value; the unique id breaks ties so no item is skipped or duplicated.

How can you provide totals without slowing every request?

Answer

Make totals opt-in, cache them, use approximate counts (e.g., PostgreSQL planner estimates), or return 'has_more' instead of an exact total.

Open in the app to mark lessons complete, take notes and track your progress.