git clone https://github.com/Intense-Visions/harness-engineering
T=$(mktemp -d) && git clone --depth=1 https://github.com/Intense-Visions/harness-engineering "$T" && mkdir -p ~/.claude/skills && cp -r "$T/agents/skills/codex/api-pagination-keyset" ~/.claude/skills/intense-visions-harness-engineering-api-pagination-keyset-8cff11 && rm -rf "$T"
agents/skills/codex/api-pagination-keyset/SKILL.mdKeyset Pagination
KEYSET PAGINATION NAVIGATES LARGE RESULT SETS BY REMEMBERING THE LAST SEEN ROW'S SORT KEY RATHER THAN COUNTING SKIPPED ROWS — EACH PAGE QUERY BECOMES AN EFFICIENT INDEX SEEK THAT PERFORMS IDENTICALLY WHETHER YOU ARE ON PAGE 1 OR PAGE 1,000,000, MAKING IT THE ONLY PAGINATION STRATEGY THAT SCALES RELIABLY BEYOND TEN MILLION ROWS.
When to Use
- Paginating tables with 10M+ rows where deep offset scans cause unacceptable query latency
- Designing an export or ETL endpoint that iterates all rows of a large table without holding a database cursor open
- Building an audit log or event stream where strict sequential consistency matters more than random access
- Replacing an offset pagination endpoint that has hit Elasticsearch's or PostgreSQL's deep-offset performance wall
- Implementing cursor pagination and choosing what data to encode inside the cursor token
- Designing a list endpoint for a resource sorted by a composite key (e.g.,
)tenant_id + created_at + id - Reviewing a PR that uses OFFSET on a table expected to exceed one million rows
- Evaluating performance characteristics of keyset vs. cursor vs. offset for a specific access pattern
Instructions
Key Concepts
-
The Seek Method — Keyset pagination uses a
predicate on the sort key columns rather thanWHERE
. Given the last seen row with sort valuesOFFSET
, the next page query is(col_a = v_a, col_b = v_b)
. The database resolves this with a single index seek toWHERE (col_a, col_b) > (v_a, v_b) ORDER BY col_a, col_b LIMIT n
and reads forward — no rows are scanned and discarded. Query cost is constant regardless of depth.(v_a, v_b) -
Composite Key Design — The sort key must uniquely identify a row to guarantee no rows are skipped or duplicated across page boundaries. A single non-unique column (e.g.,
) is insufficient. A composite key ofstatus
— where(sort_column, id)
is the unique primary key — always produces a unique, stable sort position even whenid
has duplicate values.sort_column-- Wrong: status is not unique; rows with identical status straddle pages arbitrarily WHERE status > 'active' ORDER BY status -- Correct: (status, id) is unique and stable WHERE (status, id) > ('active', 8821) ORDER BY status, id -
Sort Order Stability — The sort order applied in the
clause must exactly match theWHERE
clause. Mixing ascending and descending directions across columns requires adjusting the row-value comparison accordingly:ORDER BY-- All DESC: reverse the inequality WHERE (created_at, id) < ('2024-03-15T10:22:00Z', 8821) ORDER BY created_at DESC, id DESCAn index on
resolves this with a single seek.(created_at DESC, id DESC) -
Index Requirements — Keyset pagination is only fast when a composite index exists on exactly the columns used in the
andWHERE
clauses, in the same order and direction. Missing or misaligned indexes cause full table scans that are worse than offset pagination. Verify the query plan shows an index seek (ORDER BY
orIndex Scan
in PostgreSQL, notIndex Only Scan
).Seq Scan -
No Random Access — Keyset pagination cannot jump to page N without traversing pages 1 through N-1. There is no equivalent of
. Callers must follow the?page=50
token sequentially. This makes keyset pagination incompatible with numbered page controls but ideal for sequential iteration and infinite scroll.next_key -
Null Handling — Null values in sort key columns require explicit handling. In SQL,
sorts last in ascending order and first in descending order by default (database-dependent). If sort key columns are nullable, add aNULL
constraint in theIS NOT NULL
clause or encode null handling explicitly in the cursor.WHERE
Worked Example
Stripe's Events API exposes a high-volume append-only log of all events for an account. Stripe uses keyset pagination internally, surfacing it via
starting_after and ending_before parameters that accept resource IDs (opaque to callers but encoded from the primary key + timestamp):
Request first page of events:
GET /v1/events?limit=3&type=payment_intent.succeeded Authorization: Bearer sk_example_...
HTTP/1.1 200 OK Content-Type: application/json { "object": "list", "data": [ { "id": "evt_001", "type": "payment_intent.succeeded", "created": 1710500400 }, { "id": "evt_002", "type": "payment_intent.succeeded", "created": 1710500200 }, { "id": "evt_003", "type": "payment_intent.succeeded", "created": 1710500100 } ], "has_more": true, "url": "/v1/events" }
Request next page using
with the last seen ID:starting_after
GET /v1/events?limit=3&type=payment_intent.succeeded&starting_after=evt_003 Authorization: Bearer sk_example_...
HTTP/1.1 200 OK { "object": "list", "data": [ { "id": "evt_004", "type": "payment_intent.succeeded", "created": 1710500000 }, { "id": "evt_005", "type": "payment_intent.succeeded", "created": 1710499900 }, { "id": "evt_006", "type": "payment_intent.succeeded", "created": 1710499800 } ], "has_more": true }
The underlying query is equivalent to:
SELECT * FROM events WHERE type = 'payment_intent.succeeded' AND (created, id) < (1710500100, 'evt_003') ORDER BY created DESC, id DESC LIMIT 4 -- fetch 4 to determine has_more
Anti-Patterns
-
Using a non-unique column as the sole keyset column. If two rows share the same value in the sort column, the seek predicate
may skip one of them or include both depending on which side of the page boundary they fall. Rows in the tie zone shift unpredictably as pages are requested. Always add the primary key as a tiebreaker to make every sort position unique.WHERE col > value -
Applying keyset pagination without a matching composite index. The entire performance benefit of keyset pagination depends on an index seek. If the index does not exist or is ordered differently than the query, the database falls back to a sequential scan. Always run
before deploying a keyset-paginated endpoint and confirm the plan shows an index seek. A missing index on a 50M-row table can make page 2 slower than offset page 1.EXPLAIN -
Trying to support random page access with keyset pagination. Adding a
shortcut to a keyset endpoint requires executing N-1 seeks to find the start of page N, which is equivalent to the offset scan keyset is designed to avoid. If random access by page number is genuinely required, use offset pagination for those endpoints and keyset for the sequential-export endpoints. Do not hybridize.?page=N -
Returning the raw sort key in the API response without encoding it. Returning
leaks the internal sort schema and prevents future changes to the sort key design. Wrap the keyset values in a base64-encoded opaque cursor token, just as with cursor pagination, so the encoding can evolve without a breaking API change."next_after_created": "2024-03-15T10:22:00Z"
Details
Row-Value Comparison Support
| Database | Row-value comparison | Composite index seek | Notes |
|---|---|---|---|
| PostgreSQL 14+ | yes | yes | Full support; index-only scan possible |
| MySQL 8.0+ | yes | yes | Supported; verify index direction matches |
| SQLite 3.37+ | yes | yes | Full support |
| SQL Server | limited | partial | Use equivalent AND/OR expansion |
| DynamoDB | yes (via LastEvaluatedKey) | yes (sort key) | Native keyset via |
For SQL Server, expand row-value comparison manually:
WHERE (created_at < @v_created) OR (created_at = @v_created AND id < @v_id)
Real-World Case Study: Shopify Admin API
Shopify's Admin REST API historically used offset pagination on high-volume endpoints like
GET /admin/api/2024-01/orders.json. As merchant stores scaled to millions of orders, deep offset queries (?page=500&limit=250) caused timeout errors on the database tier. Shopify migrated these endpoints to keyset pagination using page_info cursor tokens (base64-encoded keyset values) in 2020. After migration, Shopify reported that 95th-percentile query latency for paginated order list requests dropped from 3.2 seconds (at deep offsets) to under 40ms at any page depth — an 80x improvement at the tail. The offset-based page parameter was deprecated and removed in API version 2021-04.
Source
- Use The Index, Luke — The Seek Method (No Offset)
- Shopify API — Paginate results with the REST Admin API
- Stripe API — Pagination
- PostgreSQL — Row Constructors
- Markus Winand — We need tool support for keyset pagination
Process
- Identify the sort columns: choose an immutable or append-only column (e.g.,
) as the primary sort key and add the unique primary key as a tiebreaker to form a composite sort key.created_at - Verify a composite index exists on
in the exact direction matching the query. Run(sort_col ASC/DESC, id ASC/DESC)
to confirm an index seek, not a sequential scan.EXPLAIN - Implement the seek predicate as a row-value comparison:
for ascending,WHERE (sort_col, id) > (last_sort_val, last_id)
for descending.< - Encode the last row's sort key values as a base64-encoded opaque cursor token in the response. Accept this token as the
parameter on subsequent requests. Validate the token on decode.after - Fetch
rows and return onlyLIMIT n+1
; setn
if the extra row exists. Do not run a separate COUNT query.has_more = true
Harness Integration
- Type: knowledge -- this skill is a reference document, not a procedural workflow.
- No tools or state -- consumed as context by other skills and agents.
- related_skills: api-pagination-cursor, api-pagination-offset, db-btree-index, db-covering-index
Success Criteria
- Every keyset-paginated endpoint has a composite index on its sort key columns; query plans show index seeks, not sequential scans.
- Sort keys are composite — primary sort column plus primary key — ensuring uniqueness at every page boundary.
- Keyset values returned to clients are wrapped in opaque base64-encoded cursor tokens, not exposed as raw column values.
is determined by fetchinghas_more
rows; no COUNT(*) query runs per page request.n+1- Deep-page latency (page 10,000+) is within 10% of first-page latency under production load.