Implement correct, fast API pagination — cursor vs offset trade-offs, opaque cursor encoding, stable sort keys, page-size limits, total-count costs, and consumer-side iteration that survives inserts, deletes, and retries. With runnable cursor checks.
Scanned 9/6/2026
Install to Claude Code
npx -y skills add vanara-agents/skills --skill api-pagination --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Api Pagination?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/vanara-agents-api-pagination)More formats (shields.io, HTML) on the badges page.
---
name: api-pagination
description: Implement correct, fast API pagination — cursor vs offset trade-offs, opaque cursor encoding, stable sort keys, page-size limits, total-count costs, and consumer-side iteration that survives inserts, deletes, and retries. With runnable cursor checks.
type: skill
version: 1.0.0
updated: 2026-07-10
---
# API Pagination
Pagination looks trivial until data changes underneath the reader. Offset pagination silently
**skips or duplicates rows** when inserts land mid-iteration; cursor pagination survives churn but
demands a stable sort key and an opaque token contract. This skill covers both sides: designing the
API and consuming one correctly. Deep detail lives in `references/`; copy-paste material in
`examples/`; a runnable cursor round-trip check in `scripts/`.
## Decision: offset vs cursor
| Situation | Use |
|---|---|
| Admin tables, small datasets, "jump to page 7" required | Offset (`LIMIT/OFFSET`) |
| Feeds, sync endpoints, anything users scroll | Cursor (keyset) |
| Data changes while clients iterate | Cursor — offset will skip/duplicate |
| Deep pages (offset > ~10k rows) | Cursor — `OFFSET n` scans and discards n rows |
Offset's failure mode is correctness, not just speed: a row inserted before your current position
shifts everything, so page 3 re-shows an item from page 2 (duplicate) or swallows one (skip).
## Keyset mechanics (the part people get wrong)
The sort key must be **unique and immutable**. `created_at` alone is not unique — two rows with the
same timestamp make the cursor ambiguous and rows vanish. Always add a tiebreaker:
```sql
-- WHERE clause for "next page after (2026-07-01T12:00:00Z, id 4711)" descending
SELECT * FROM items
WHERE (created_at, id) < ('2026-07-01T12:00:00Z', 4711)
ORDER BY created_at DESC, id DESC
LIMIT 51; -- page_size + 1 to detect has_more without COUNT(*)
```
Fetch `page_size + 1` rows: if you get 51, there is a next page and the 51st row's key becomes the
next cursor. This avoids `COUNT(*)`, which on large tables costs more than the page itself
(see `references/counting-and-totals.md`).
## Cursor token contract
Cursors are **opaque to clients** — never let them parse or build one. Encode the keyset values plus
the sort direction, base64url it, and (for public APIs) HMAC it so tampering is detectable. Include a
version byte so you can change the encoding later. Full recipe: `references/cursor-encoding.md`;
runnable round-trip: `scripts/check-cursor.mjs`.
## Response envelope
```json
{
"data": [ ... ],
"page_info": { "has_more": true, "next_cursor": "djEuMj..." }
}
```
Rules: `next_cursor` is absent (not empty-string) on the last page; page size is capped server-side
(a `limit=10000` request gets the cap, documented, not an error); the default page size appears in
the docs and never changes silently. Consumer-side iteration rules — retries, cursor expiry,
resumption — live in `references/consuming-pages.md`.
## Pitfalls
- **Mutable sort keys** (`updated_at`) — rows teleport across pages as they update. Sort on immutable
columns or accept re-delivery and dedupe client-side.
- **Cursor built from row position** instead of row values — it is offset with extra steps and
inherits every offset bug.
- **`COUNT(*)` on every page** — the count is stale by the time the client renders it; provide totals
on a separate, cacheable endpoint if the product truly needs them.
- **Breaking cursor encoding on deploy** — clients hold cursors across releases; version the token.
- **Missing composite index** — keyset needs an index on exactly `(created_at, id)` in sort order, or
every page is a scan.
## Verification
Run `node scripts/check-cursor.mjs` — encodes, decodes, tampers, and versions a cursor and asserts
every property above. For the SQL side, `examples/keyset-queries.sql` includes an `EXPLAIN` you can
run to confirm the index is used. Pairs with `rest-api-design` (envelopes, limits) and
`sql-index-tuning` (composite indexes).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!