1.Page numbers, limit/offset, and cursors — and stable ordering
Never return an unbounded result set. The three ways to bound one are page numbers (`?page=7`), limit/offset (`?limit=50&offset=300`), and cursors (`?after=<opaque token>`). Page numbers and limit/offset are the same thing underneath — both become SQL `OFFSET`, and the database has to walk and discard every skipped row, so page 2,000 costs far more than page 2. Cursors instead say "give me rows after this one", which is a `WHERE` the database can satisfy with an index and costs the same on every page. All three are broken by an unstable sort: if two rows can tie, the order between them is undefined, and rows silently repeat or disappear between pages.
Key features
- `?page=N` and `?limit=&offset=` compile to the same `LIMIT/OFFSET` SQL — the difference is the URL, not the cost
- `OFFSET n` makes the database produce and discard `n` rows, so deep pages get progressively more expensive
- A cursor is a `WHERE (sort_key, id) < (…, …)` comparison — an index seek, constant cost per page
- Any sort that can tie is not a total order: append a unique column, e.g. `order_by("-placed_at", "-id")`
- Without a tiebreaker, rows repeat or vanish between pages, and re-running the same page can return different rows
- Django's `Paginator` runs a `COUNT(*)` for `num_pages`; on a large filtered table that count can cost more than the page
Three shapes, and what each one is actually for
- page numbers — fine: first few pages only, data rarely changes — a catalogue browsed a page or two deep; OFFSET depth never gets large
- page numbers — slow: deep or full traversal, data rarely changes — page 2,000 makes the database walk and discard 100,000 rows to return 50
- page numbers — wrong: first few pages only, rows inserted constantly — inserts shift the boundary: the reader sees a row twice, or never sees it
- cursor — the only correct option: deep or full traversal, rows inserted constantly — a value-based boundary that inserts cannot move, and an index can seek to
- compound ordering is required in all four: between first few pages only and deep or full traversal, between data rarely changes and rows inserted constantly — ("-placed_at", "-id") — a sort that can tie is not a total order
Command
qs.order_by("-placed_at", "-id") # total order: the tiebreaker is unique
qs[300:350] # LIMIT 50 OFFSET 300 — walks 300 rows firstPage numbers and limit/offset are the same SQL, and both make the database walk and discard everything you skipped — cost grows with depth, and the boundary moves whenever rows are inserted. A cursor makes the boundary a *value* instead of a count, so it is index-seekable and insert-safe. Whichever you pick, append a unique tiebreaker to the ordering: a sort that can tie is not a total order, and the symptom of getting this wrong is duplicated and missing rows that change on every run. Cap addressable page depth, and send full traversal to a cursor or an export job.