Page numbers, limit/offset, and cursors — and stable ordering
coreintermediateNever 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.
Think of it as
Ask what the page boundary is *made of*. With `OFFSET`, the boundary is a count — "skip 300 rows" — which means the database must produce and discard those 300 rows first, and it also means the boundary moves whenever the underlying data changes. Someone inserting a row while a user reads page 3 pushes one row from page 3 onto page 4, so the user sees it twice; a deletion makes a row vanish unseen. With a cursor, the boundary is a *value* — "rows after (2026-09-05T10:00, id 8412)" — which does not move when other rows are inserted or deleted, and which an index on the same columns can seek to directly. That is the whole trade: `OFFSET` gives you random access to any page and gets slower the deeper you go; a cursor gives you cheap sequential access and no page numbers. Stable ordering is the part that decides whether either works at all. Sorting by `-created_at` alone is not a total order when two rows share a timestamp, and the database is free to return ties in any order — including a different order on the next query for the same page. Appending a unique tiebreaker (`("-created_at", "-id")`) makes the order total, which is what makes both the offset boundary and the cursor comparison well defined. This is not a subtle correctness issue you can defer: the symptom is duplicated and missing rows in a paginated export, and it is nearly impossible to diagnose from a bug report.
What we're doing: Show the same listing paginated three ways, and what each one does at depth and under inserts.
- 5–7
- Two costs, both invisible in the code: the discarded rows behind `OFFSET`, and the `COUNT(*)` the `Paginator` runs to know how many pages exist.
- 11–15
- The failure that motivates the tiebreaker. A bulk import gives many rows an identical `placed_at`, the database orders ties arbitrarily, and the arbitrary order can differ between the two queries that produce page 3 and page 4.
- 22–26
- The cursor comparison in full. The `Q` pair is the row-value comparison `(placed_at, id) < (…, …)` written out, and the second half is what handles rows sharing a timestamp.
- 28
- Fetching one extra row answers "is there a next page" without a `COUNT(*)`, which is often the single biggest saving on a large filtered table.
- 32–34
- The cursor is derived from the last row returned, so it is a value rather than a position — inserts and deletes elsewhere cannot move it.
Why this works: The three implementations are the same listing, and the differences are entirely in the boundary: a count that moves and gets expensive, versus a value that is stable and indexable.
Ordering by a non-unique column and paginating it
Wrong
Better
What you see: A paginated export produces a file with duplicate rows and missing rows, in numbers small enough to look like a data problem rather than a pagination problem. Re-running it produces a *different* set of duplicates.
Why: SQL makes no promise about the order of rows that tie on the `ORDER BY` key, and it is free to return them differently between executions — a different plan, a different degree of parallelism, or a different physical layout is enough. `LIMIT/OFFSET` slices that unstable sequence at fixed positions, so any reshuffling around the boundary moves rows across it. Appending a unique column makes the order total, so there is no freedom left and the slice is well defined. The same requirement applies to cursors, where the tiebreaker is also what the comparison uses to resume.
- 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
Three shapes, and what each one is actually for
Together
Remember: Page 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.
See also: iterator batching and streaming responses · cursor pagination and stable ordering · database side updates and batched deletes

