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.
In this repository29 other files it gives its agents.
- algorithmic-complexity-attacksSkill
- cache-stampedeSkill
- clocks-and-timeSkill
- code-hotspotsSkill
- connascenceSkill
- connection-poolingSkill
- consistent-hashingSkill
- coordinated-omissionSkill
- cyclomatic-complexitySkill
- database-queuesSkill
- dependency-confusionSkill
- expand-contractSkill
- floating-pointSkill
- hyrums-lawSkill
- index-designSkill
- isolation-anomaliesSkill
- metamorphic-testingSkill
- module-depthSkill
- mutation-testingSkill
- mvcc-and-bloatSkill
- parse-dont-validateSkill
- query-plansSkill
- queueing-and-utilizationSkill
- retry-amplificationSkill
- start-hereSkill
- tail-latencySkill
- unicode-pitfallsSkill
- universal-scalability-lawSkill
- write-amplificationSkill
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
- Distinctive: line 65
Do not expose raw column values, since the sort key may need to change later.
- Very specific: line 125
Add a unique tiebreaker to the ORDER BY
- Very specific: line 126
Create a composite index matching the full sort order and direction, including DESC.
- Very specific: line 129
Use LIMIT n+1 for hasNextPage instead of COUNT(*)
≈ “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.
Files
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 againUsers 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_atThe 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].idCorrect, 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
- Add a unique tiebreaker to the
ORDER BY - Create a composite index matching the full sort order and direction, including
DESC. Seeindex-design - Replace
OFFSETwith a row-value comparison on the same tuple - Return an opaque signed cursor instead of a page number
- Use
LIMIT n+1forhasNextPageinstead ofCOUNT(*) - Confirm with
EXPLAIN ANALYZEthat a deep page reads aboutLIMITrows. Seequery-plans
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
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.

