Paginating API responses

API design: REST, HTTP, GraphQL and gRPC Mid-level Python 3.13+ PostgreSQL 17+ Reviewed By Best Latest Apps

Predict the output

What does decoding the cursor print?

Python 3.13
import base64
import json


def encode(created_at: str, row_id: int) -> str:
    raw = json.dumps([created_at, row_id]).encode()
    return base64.urlsafe_b64encode(raw).decode().rstrip("=")


def decode(token: str) -> tuple[str, int]:
    pad = "=" * (-len(token) % 4)
    ts, row_id = json.loads(base64.urlsafe_b64decode(token + pad))
    return str(ts), int(row_id)


t = encode("2026-10-31T12:00:00Z", 7)
print(decode(t))

Options
Hint 1 of 1

Look at decode's return type.

Choose an answer, then open Show answer below to check it.

Show answer

Answer: A ('2026-10-31T12:00:00Z', 7)

Correct. The cursor round-trips the last row's sort key and id.

The token is base64url-encoded JSON of the last row's sort key and id. decode puts the stripped padding back, parses the list and converts it to a typed tuple, so the next page starts exactly after that row.

Why not the others?
  • B ['2026-10-31T12:00:00Z', 7]. decode returns a tuple, not the raw JSON list.
  • C A padding error. decode adds the stripped '=' padding back.
  • D ('2026-10-31T12:00:00Z', '7'). row_id is converted with int().

30-second answer

Use cursor pagination: return a page of items plus an opaque next cursor that encodes where the page ended (for example the last sort key and id). The client passes it back to get the next page. Unlike offset pages, cursors don't skip or repeat items when rows are inserted, and each page costs the same to fetch. Put a server-enforced maximum on the page size.

Practise saying it · 35 s. No recording, just a timer

How should an API paginate?

Say your answer out loud, then check which of these points you covered:

  • Opaque cursor
  • Stable under inserts
  • Constant cost per page
  • Max page size
  • Tie-breaker id

Deep dive

Make the cursor opaque (base64 of JSON) so you can change its contents later; sign or encrypt it if it holds anything sensitive.

Return links (next, prev) or a next_cursor field; no next cursor means the end.

Total counts are expensive on big tables; make them optional or approximate.

Filters and sort order must be part of the cursor or repeated identically on every page.

Common mistakes

  • Offset pagination on a feed that changes every second.
  • Exposing database ids and timestamps as raw, guessable query params.
  • No maximum page size.

Senior take & follow-ups

Explain cursors vs offsets, opaque tokens, a max limit and the tie-breaker.

Follow-up questions

Think of your answer first, then open the hint.

  1. How do you support 'jump to page 50'?

    Show hint

    You mostly don't with cursors; offer search and filters instead.

  2. What if the sort column isn't unique?

    Show hint

    Add the id as a tie-breaker in the cursor.

Suggest a fix if something here is wrong or out of date.