How do you rewrite LIMIT/OFFSET paging as a keyset (seek) query?
answer
- stop counting rows, start comparing values
- remember the last row you showed
- filter on the sort key instead of skipping
- row-value comparison mirroring the ORDER BY
basics
~20 sRemember the sort-key values of the last row shown and filter on them instead of counting rows: WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20. Every page then costs the same as page one.
solid answer
~50 sKeyset pagination replaces "skip n rows" with "start after this value". The client sends back the sort-key values of the last row it displayed, and the next page is `WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20`. The predicate is a **row-value comparison**, which compares lexicographically — it means `created_at < :last_created_at OR (created_at = :last_created_at AND id < :last_id)`, and that expanded `OR` form is the portable fallback where an engine does not support the row-constructor spelling in a comparison. Three rules make it correct: the `ORDER BY` must end in a unique column so the boundary is unambiguous, the predicate columns and directions must match the `ORDER BY` exactly, and the sort columns should be `NOT NULL`. The first page simply omits the `WHERE` clause. The tradeoff is that you get next/previous, not "jump to page 57".
code
sql · 14 lines-- WRONG: not equivalent to the sort order; silently drops rows
SELECT id, created_at, title
FROM posts
WHERE created_at <= :last_created_at
AND id < :last_id
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- CORRECT: lexicographic row-value comparison matching the ORDER BY
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;go deeper
Learn the shape: filter on the last row's sort values instead of skipping rows, and keep the ORDER BY the same. Being able to write the two-column seek query from memory is enough at this level.
Explain that a row-value comparison is lexicographic, why the AND-chain rewrite silently loses rows, and the three rules — unique tie-breaker, predicate mirroring the ORDER BY, NOT NULL sort columns.
Show the migration judgment: how you carry the anchor through the API, how you handle the first page and exhausted pages, and what you did about the UI features (page numbers, totals) that keyset cannot serve.
Own the contract implications. Cursor-based listings change what clients can ask for; decide once how anchors are encoded and versioned across services, and how a sort or filter change invalidates outstanding cursors.
## The idea Offset pagination asks a positional question — "which rows are at positions 201 to 220?" — and positions can only be found by counting. Keyset (also called seek) pagination asks a value question — "which 20 rows come after *this* row in the sort order?" — and a value is something the engine can position on directly. That single change makes every page cost the same as the first. ## The rewrite ```sql -- Offset version: cost grows with the page number SELECT id, created_at, title FROM posts ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 200000; -- Keyset version: constant cost per page SELECT id, created_at, title FROM posts WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20; ``` The parameters are the `created_at` and `id` of the **last row of the previous page**. Everything the next page needs is carried in those two values; the server keeps no state. The first page has no anchor, so it simply omits the `WHERE` clause (or the application builds the statement without it). Do not invent a sentinel value like `'9999-12-31'` for this — omitting the predicate is clearer and cannot go wrong at the type boundary. ## Row-value comparison semantics `(a, b) < (x, y)` is standard SQL and compares **lexicographically**, exactly the way the corresponding `ORDER BY a, b` sorts: it is true when `a < x`, or when `a = x` and `b < y`. It is emphatically *not* `a < x AND b < y`. That distinction is where the classic bug lives: ```sql -- WRONG: silently drops rows WHERE created_at <= :last_created_at AND id < :last_id ``` This demands that *every* returned row have an id below the anchor's id, which has nothing to do with the sort order. Rows older than the anchor whose id happens to be larger vanish from the results entirely — and because the pages still look plausible, the loss is usually discovered much later. The correct expanded form is: ```sql WHERE created_at < :last_created_at OR (created_at = :last_created_at AND id < :last_id) ``` This is logically identical to the row-value comparison and is the portable spelling: engines differ in whether they accept row constructors in comparison predicates and in how well they can use an index for them, so check your engine before committing to the compact form. ## The three correctness rules 1. **The ordering must be total.** The last sort column must be unique — usually the primary key. Without it, ties at the page boundary make the anchor ambiguous and rows are skipped or repeated. 2. **The predicate must mirror the ORDER BY.** Same columns, same sequence, same directions. `DESC` ordering pairs with `<`, `ASC` with `>`. If the directions are mixed (`a ASC, b DESC`), a single row-value comparison cannot express the boundary — you must write the expanded `OR` form with the correct operator per column. 3. **The sort columns should be NOT NULL.** Any comparison involving NULL yields UNKNOWN, so the row is not returned; a nullable sort column silently truncates paging at the first NULL. If you must sort on a nullable column, seek on a NOT NULL surrogate or an expression that eliminates the NULL. A fourth practical rule: the filters and the `ORDER BY` must stay identical across every page of the same traversal. The anchor is meaningful only relative to one ordering — change the sort and the cursor must be discarded. ## What the client hands back The anchor is just the sort-key values of the last displayed row. Applications usually opaque-encode them into a single token so callers cannot craft them, but at the SQL level they are ordinary bind parameters. Note that the anchor values must be the ones the query sorted on, not a display-formatted version of them: a timestamp truncated to seconds in JSON will land on the wrong row when the column holds microseconds. ## What you give up Keyset gives next and previous, not random access — there is no way to express "page 57" without counting, which is the very thing you removed. It also does not give you a total row count; that needs a separate aggregate query if the UI insists on it. And it needs a sort key that is stable while a user pages: ordering by a score that changes every few seconds will still shift rows around. For most feeds, activity logs and infinite-scroll lists those are acceptable prices, and the payoff is that page 10,000 costs exactly what page 1 costs.
- What exactly does the client send back to request the next page?The sort-key values of the last row it displayed — here `created_at` and `id` — as ordinary bind parameters, plus the same `ORDER BY` and filters. Send the raw stored values, not display-formatted ones: a timestamp rounded to seconds in JSON will anchor on the wrong row when the column stores microseconds.
- Why keep the expanded OR form around if row-value comparison is standard SQL?Because engines differ on whether they accept row constructors in comparison predicates at all, and on whether they can use an index for one. The expanded form — `a < :a OR (a = :a AND b < :b)` — is logically identical and universally accepted, so it is the safe spelling for portable code or when your engine handles the compact form poorly.
- How would you page with ORDER BY created_at ASC, id DESC — mixed directions?A single row-value comparison cannot express it, because row comparison is lexicographic in one direction only. Write the expanded form with the right operator per column: `created_at > :last_created_at OR (created_at = :last_created_at AND id < :last_id)`. Mixed-direction ordering is also harder for an index to serve, so prefer a consistent direction where the product allows it.
saying these in an interview costs you the question
- Writes created_at <= :ts AND id < :id and calls it equivalent
- Thinks (a,b) < (x,y) means a < x AND b < y
- Claims keyset pagination can jump to an arbitrary page number
- Uses WHERE id > :last_id while ordering by something other than id
- Forgets the ORDER BY must match the seek predicate exactly