agentleFS
auralshinauralshin/coding-skills/skills/keyset-pagination/SKILL.md

keyset-pagination

Skill0 starsChanged 3 months agoMITView on GitHub

This skill should be used when the user asks "why is deep pagination slow", "how do I replace OFFSET", "how do I build a cursor-based API", "why do users see duplicate or skipped rows while paging", or is writing a batch job, export, or backfill that pages through a large table.

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 1 is distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Do not expose raw column values, since the sort key may need to change later.

    line 65
  2. Very specific:

    Add a unique tiebreaker to the ORDER BY

    line 125
  3. Very specific:

    Create a composite index matching the full sort order and direction, including DESC.

    line 126
  4. Very specific:

    Use LIMIT n+1 for hasNextPage instead of COUNT(*)

    line 129

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Keyset pagination

Why OFFSET fails

SELECT * FROM events ORDER BY created_at DESC LIMIT 20 OFFSET 200000;

The database cannot jump to row 200,000. It must produce and discard 200,000 rows to reach the 20 wanted. Cost is O(offset), so page 10,000 costs 10,000 times page 1.

Two separate failures:

It slows with depth. Fine on 500 development rows. A production timeout on deep pages only, affecting crawlers and export scripts.

It returns wrong results on live data. Rows inserted and deleted between requests shift the offset:

t0: user reads page 1 (rows 1-20)
t1: 3 rows are inserted at the top
t2: user reads page 2 (OFFSET 20) -> rows 18, 19, 20 appear again

Users see duplicates and rows silently vanish. Any job paging with OFFSET while writes happen will skip records, which is how backfills quietly miss rows.

Keyset: resume from a value

Carry the sort key of the last row and ask for what follows it.

-- page 1
SELECT id, created_at, title FROM events
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- page 2, passing the last row's (created_at, id)
SELECT id, created_at, title FROM events
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;

With an index on (created_at DESC, id DESC) this is an index seek to the position plus 20 rows: constant time at any depth, and stable against concurrent inserts because it resumes from a value rather than a position.

Row-value comparison

(a, b) < (x, y) is lexicographic comparison, and Postgres and MySQL 8 turn it directly into an index seek. Write it that way:

WHERE (created_at, id) < ($1, $2)                          -- correct, one seek
WHERE created_at < $1 OR (created_at = $1 AND id < $2)     -- equivalent, often planned worse
WHERE created_at < $1                                      -- WRONG: skips rows sharing created_at

The third form is the bug people ship. When any two rows share a created_at, rows are skipped at page boundaries.

Always append a unique tiebreaker (the primary key) to both the ORDER BY and the comparison. Without a total order, pagination cannot be correct.

Cursors

Do not expose raw column values, since the sort key may need to change later.

cursor = base64.urlsafe_b64encode(
    json.dumps({"t": last.created_at.isoformat(), "i": last.id}).encode())

Sign it (HMAC) so clients cannot forge one, and include the sort field name so a cursor from a different ordering is rejected rather than returning nonsense.

Opacity matters for a second reason: a readable encoding will be constructed by hand and can then never change. See hyrums-law.

Bidirectional paging

Reverse the comparison and sort, then reverse the result in application code:

SELECT * FROM (
  SELECT id, created_at, title FROM events
  WHERE (created_at, id) > ($1, $2)
  ORDER BY created_at ASC, id ASC
  LIMIT 20
) t ORDER BY created_at DESC, id DESC;

To know whether a next page exists without a COUNT, fetch LIMIT 21 and return 20. The 21st row's presence is hasNextPage. This is what the GraphQL Relay connection spec encodes.

What is given up

Keyset pagination cannot jump to an arbitrary page number, because computing where page 500 starts is the O(offset) work being removed.

This is usually acceptable, since infinite scroll and next/previous cover most usage. Where numbered pages are required:

  • Offer numbered pages for the first N pages where OFFSET is cheap, and keyset beyond.
  • Or paginate over a dense indexed sequential column and jump by value.
  • Or precompute page boundaries for a static dataset.

Note also that COUNT(*) for a total is a separate expensive query and often the real cost of a list endpoint. Prefer hasNextPage, or use an estimate:

SELECT reltuples::bigint FROM pg_class WHERE relname = 'events';

Batch processing

The same technique, and where it matters most. Anything paging with OFFSET while writes happen will skip rows.

last_id = 0
while True:
    rows = db.query(
        "SELECT id, ... FROM users WHERE id > %s ORDER BY id LIMIT 1000", last_id)
    if not rows: break
    process(rows)
    last_id = rows[-1].id

Correct, constant cost per batch, and resumable: persist last_id and a crashed job restarts where it stopped. Use this shape for every backfill, export, and migration script. See expand-contract.

Migration checklist

  1. Add a unique tiebreaker to the ORDER BY
  2. Create a composite index matching the full sort order and direction, including DESC. See index-design
  3. Replace OFFSET with a row-value comparison on the same tuple
  4. Return an opaque signed cursor instead of a page number
  5. Use LIMIT n+1 for hasNextPage instead of COUNT(*)
  6. Confirm with EXPLAIN ANALYZE that a deep page reads about LIMIT rows. See query-plans

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.