Pagination and Filtering skill for Claude Code
Page one and page ten thousand cost the same. Claude builds list endpoints on keyset pagination with a unique tiebreaker instead of OFFSET, so deep pages stay fast and concurrent inserts never duplicate rows. It signs opaque cursors bound to the sort and filters, whitelists filter and sort syntax, creates an index for each allowed shape, replaces exact counts with estimates or capped counts, and proves it with EXPLAIN and a paging-under-writes test.
- name
pagination-filtering - size226 lines · ≈2,450 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Implements list endpoints that stay fast and correct at any depth - keyset (cursor) pagination as the default over offset, opaque signed cursors bound to sort and filters, a total sort order with a unique tiebreaker, whitelisted filter and sort syntax, page-size limits, the real cost of total counts and cheaper alternatives, a matching index for every allowed sort, GraphQL connection mapping, and behavior under concurrent inserts, with PostgreSQL and MySQL SQL. Use when building or reviewing a list or search endpoint, when deep pages get slow, when users see duplicate or missing rows while paging, or when adding filters and sort options. Try asking
- Our /orders endpoint gets slower the deeper you page. Switch it to cursor pagination
- Users see the same item twice when scrolling the feed. Find out why and fix it
- Add status, date range and sort filters to the products list endpoint with the right indexes
Use it when
- Building or reviewing a list, feed or search endpoint
- Deep pages are slow or COUNT(*) dominates response time
- Rows repeat or go missing while users page or scroll
- Adding filter and sort parameters that need index support
Skip it when
- You are designing the rest of the REST contract (status codes, errors, idempotency) — use rest-api-design
- The slow part is a single query plan unrelated to paging — use sql-query-tuning
- The problem is rendering thousands of rows in the browser — use data-tables
How to check the result
EXPLAIN on page 1 and a deep page shows the same index scan with no large Sort node, and a test paging through while rows are inserted finds no duplicates or gaps.
The common mistake it prevents
Ordering by created_at without a unique tiebreaker, so rows that share a timestamp shuffle between queries and appear twice or never.
A list endpoint is done well when every page costs about the same no matter how deep the client goes, paging through while rows are inserted never repeats or skips a row, the order is total and documented, every filter and sort the API accepts is backed by an index, page size is capped, and counts are only as exact as the product actually needs.
Ground rules
- Default to keyset (cursor) pagination. Use offset only for small, bounded lists where users jump to page N (admin tables under roughly 10k rows).
- The sort must be total: end every
ORDER BYwith a unique column (id). Without a tiebreaker, rows sharing a timestamp come back in arbitrary order across pages. - Cursors are opaque to clients. Clients pass them back unchanged; the server may change the format at any time.
- Every list has a default and a maximum page size, including internal endpoints.
- Allowed filters and sorts are a whitelist, each with an index. Unknown parameters get 400 on new APIs, so a typo does not silently return everything.
- Match the existing API’s conventions (parameter names, envelope) before introducing new ones; consistency beats the ideal shape.
Offset vs keyset
Offset (LIMIT 50 OFFSET 5000) | Keyset (WHERE (created_at, id) < (...)) | |
|---|---|---|
| Cost of page N | Reads and discards N x size rows; grows linearly | Constant: index seek + limit |
| Concurrent inserts/deletes | Duplicates and skips across pages | No duplicates or skips for rows that existed |
| Jump to arbitrary page | Yes | No (only next/previous, or a jump to a known key such as a date) |
| Total pages | Needs a count | Not needed |
| Use for | Small admin tables, reports with page numbers | Feeds, APIs, infinite scroll, exports, anything that grows |
Workflow
- [ ] 1. Inspect: table size, existing indexes, current params and envelope
- [ ] 2. Define the sort options and a unique tiebreaker for each
- [ ] 3. Define filters and their syntax (whitelist)
- [ ] 4. Create one index per (equality filters + sort) shape that the API allows
- [ ] 5. Implement keyset queries and cursor encoding
- [ ] 6. Decide what count, if any, to return
- [ ] 7. Prove it: EXPLAIN, paging-under-inserts test, limit enforcement
1. Inspect
SELECT reltuples::bigint FROM pg_class WHERE oid = 'public.orders'::regclass; -- row estimate
SELECT indexdef FROM pg_indexes WHERE tablename = 'orders';
Read two existing list handlers to learn the envelope and parameter style.
2-3. Request and response shape
GET /v1/orders?status=paid,refunded&createdAt[gte]=2026-10-01T00:00:00Z&sort=-createdAt&limit=50
GET /v1/orders?limit=50&cursor=eyJ2IjoxLCJrIjpb...
200 OK
{
"data": [ ... ],
"nextCursor": "eyJ2IjoxLCJrIjpb...", // null on the last page
"hasMore": true
}
Syntax defaults (pick once, apply everywhere):
| Need | Syntax | Notes |
|---|---|---|
| Equality | ?status=paid | |
| Any of | ?status=paid,refunded | Cap list length (e.g. 50 values) |
| Range | ?createdAt[gte]=...&createdAt[lt]=... | Operators: eq, gt, gte, lt, lte; half-open ranges for time |
| Sort | ?sort=-createdAt (minus = descending), comma for multiple | Server appends id as the tiebreaker silently |
| Text search | ?q=... | Routed to full-text or a search engine, not LIKE '%x%' |
| Page size | ?limit=50 | Default 20-50; max 100 (larger only for tiny rows); document clamp vs 400 |
When a cursor is present, the sort and filters come from the cursor. If the client also sends different filters or sort, return 400 instead of mixing them.
Bulk export is not pagination: offer an async export job for “give me everything”.
4. Index per shape
Order index columns as: equality filters, then sort columns in sort order, then the tiebreaker.
-- serves: tenant_id = ? [AND status = ?] ORDER BY created_at DESC, id DESC
CREATE INDEX CONCURRENTLY orders_tenant_status_created_id
ON orders (tenant_id, status, created_at, id);
Each user-selectable sort needs its own index shape. Five sort options times three filter
combinations is fifteen indexes, which hurts writes; limit the sort options instead.
For index design details see postgres-indexing.
5. Keyset query and cursor
-- first page (fetch limit + 1 to know if there is more)
SELECT id, created_at, status, total_amount
FROM orders
WHERE tenant_id = $1 AND status = ANY($2)
ORDER BY created_at DESC, id DESC
LIMIT 51;
-- next page, cursor decoded to (created_at, id) of the last row returned
SELECT id, created_at, status, total_amount
FROM orders
WHERE tenant_id = $1 AND status = ANY($2)
AND (created_at, id) < ($3, $4)
ORDER BY created_at DESC, id DESC
LIMIT 51;
Handler logic:
rows = fetch(limit + 1)
has_more = len(rows) > limit
page = rows[:limit]
next_cursor = encode_cursor({
"v": 1,
"k": [page[-1].created_at.isoformat(), page[-1].id], # full precision
"s": sort_spec,
"f": filters_hash, # reject if filters change
}) if has_more else None
Cursor rules:
- Contents: version, last row’s sort-key values and id, sort spec, a hash of the filters. Encode as base64url JSON and sign with an HMAC so clients cannot forge keys that bypass filters (tenant scoping must still come from auth, never the cursor).
- Keep timestamps at database precision. Millisecond truncation of microsecond timestamps skips or repeats rows.
- Invalid or tampered cursor -> 400 with a problem document, not a 500.
MySQL needs the expanded OR form for full index use; mixed sort directions, nullable
sort columns, previous-page queries, mutable sort keys, count estimates and complete
Python and TypeScript cursor helpers are in
references/keyset-sql.md. Read it when the sort is not a
simple same-direction pair or when the database is not PostgreSQL.
ORM cursor helpers often accept only one unique column. For compound keys write the
WHERE clause explicitly rather than paging by id alone and sorting by something else.
6. Counts
COUNT(*) over a filtered large table scans every matching row (PostgreSQL has no
cached count under MVCC). Pick the cheapest option the UI accepts:
| Need | Return |
|---|---|
| “Is there more?” | hasMore only (default) |
| A rough size | Estimate from the planner, labeled totalEstimate |
| “1,000+ results” | Capped count: count(*) over a subquery with LIMIT 1001 |
| Exact total for small sets | Exact count, opt-in (?include=total), with a timeout |
| Exact totals on huge sets | Maintained counter table or materialized aggregate, updated by triggers or jobs |
Never compute an exact count on every page request by default.
7. Concurrency semantics (document them)
- Keyset with an immutable key: rows inserted after the client’s cursor position (for a DESC feed, newer rows) are not seen in this pass; rows that existed are each seen once. To show new items at the top, poll separately for rows newer than the first cursor.
- Deleted rows simply disappear; no shift.
- Offset: an insert before the current page shifts everything, so the next page repeats a row; a delete skips one.
- Sorting by a mutable column (
updated_at,score) can move rows across the cursor. Use immutable keys for listing, or freeze a snapshot bound in the cursor.
GraphQL
Use the Relay connection shape: first/after and last/before, edges { node cursor },
and pageInfo { hasNextPage hasPreviousPage startCursor endCursor } (PageInfo and both
booleans non-null; start and end cursors nullable). Back it with the same keyset query and
cursor encoding; each edge cursor encodes that row’s key. Make totalCount nullable and
resolve it only when selected, with the count rules above. For schema concerns see
graphql-schema.
Gotchas
ORDER BY created_atalone: ties at the same timestamp reorder between queries.- Sorting in application memory after
LIMIT: the page is sorted, the dataset is not. OFFSETused for “infinite scroll” over a table that grows; deep pages time out and duplicates appear.- Filters applied after pagination (in code), returning pages with 3 of 50 rows.
- Case-insensitive sort (
ORDER BY lower(name)) without an expression index onlower(name); and collation differences between the database and the cursor values. NULLin a sort column making rows vanish from keyset comparisons.limit=100000accepted because the max was only enforced in the UI.- Cursors that embed raw SQL fragments or column names chosen by the client.
Verification
EXPLAIN (ANALYZE, BUFFERS)for page 1 and a deep page: same plan, an Index Scan (or Index Only Scan) on the intended index, noSortnode over many rows, rows read close tolimit + 1, similar execution time.- Paging test: seed rows with duplicate timestamps, page through with a small limit while another connection inserts and deletes; assert no duplicates and that every row present for the whole run appears exactly once, in order.
- Limit test:
limit=0,limit=-1,limit=10000, non-numeric -> documented clamp or 400. - Cursor test: tampered cursor -> 400; cursor reused with different filters -> 400.
Output contract
## Endpoint: GET /v1/<resource>
- Pagination: keyset | offset (why), default/max limit, clamp or 400
- Sorts: <list> -> tiebreaker id, index per sort
- Filters: <param: operators> -> index
- Count: none | estimate | capped | opt-in exact
- Cursor: fields, signing, rejection rules
- Concurrency semantics (one paragraph for API docs)
## EXPLAIN evidence (page 1 vs deep page)
## Tests: paging-under-writes, limits, cursor tampering Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/keyset-sql.md
# Keyset pagination SQL cookbook
Read the section matching the case in front of you. All examples page `orders`, newest
first, 50 per page, fetching `limit + 1` rows to compute `hasMore`.
## 1. Same direction on all sort columns (PostgreSQL, SQLite 3.15+)
Row-value comparison is the shortest form and uses a matching B-tree index:
```sql
-- first page
SELECT id, created_at, status, total_amount
FROM orders
WHERE tenant_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 51;
-- next page: cursor carried (created_at, id) of the last row returned
SELECT id, created_at, status, total_amount
FROM orders
WHERE tenant_id = $1
AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 51;
CREATE INDEX CONCURRENTLY orders_tenant_created_id
ON orders (tenant_id, created_at, id); -- B-trees scan backward, so ASC index serves DESC order
```
## 2. MySQL / MariaDB
MySQL accepts row constructors but its manual notes the optimizer may use only part of
the index; the expanded form gets full range access. Use it and confirm with `EXPLAIN`
(`key_len` should cover all index columns):
```sql
SELECT id, created_at, status, total_amount
FROM orders
WHERE tenant_id = ?
AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC
LIMIT 51;
CREATE INDEX orders_tenant_created_id ON orders (tenant_id, created_at, id);
```
## 3. Mixed directions (`ORDER BY priority DESC, created_at ASC, id ASC`)
Row comparison does not work across mixed directions. Expand it:
```sql
WHERE tenant_id = $1
AND (
priority < $2
OR (priority = $2 AND created_at > $3)
OR (priority = $2 AND created_at = $3 AND id > $4)
)
ORDER BY priority DESC, created_at ASC, id ASC
LIMIT 51;
-- index with matching mixed directions so no Sort node is needed
CREATE INDEX CONCURRENTLY orders_tenant_prio_created_id
ON orders (tenant_id, priority DESC, created_at ASC, id ASC);
```
## 4. Nullable sort column
`NULL` breaks comparisons (`NULL < x` is unknown, so rows vanish). Options, in order of
preference:
1. Make the column `NOT NULL` with a sentinel default if the domain allows.
2. Sort on an expression and index the same expression:
```sql
-- shipped_at nulls last in DESC order: treat NULL as -infinity
ORDER BY COALESCE(shipped_at, '-infinity'::timestamptz) DESC, id DESC
WHERE (COALESCE(shipped_at, '-infinity'::timestamptz), id) < ($2, $3)
CREATE INDEX CONCURRENTLY orders_shipped_coalesce_id
ON orders ((COALESCE(shipped_at, '-infinity'::timestamptz)), id);
```
3. Page the non-null segment, then the null segment, with a `phase` field in the cursor.
## 5. Previous page (backward)
Flip the comparison and the order, then reverse the rows in the application:
```sql
-- cursor carries (created_at, id) of the FIRST row of the current page
SELECT * FROM (
SELECT id, created_at, status
FROM orders
WHERE tenant_id = $1 AND (created_at, id) > ($2, $3)
ORDER BY created_at ASC, id ASC
LIMIT 51
) page
ORDER BY created_at DESC, id DESC;
```
## 6. Filters plus keyset
Equality filters go first in the index, then sort columns, then the tiebreaker:
```sql
-- ?status=paid&createdAt[gte]=2026-10-01T00:00:00Z&sort=-createdAt
WHERE tenant_id = $1
AND status = $2
AND created_at >= $3
AND (created_at, id) < ($4, $5)
ORDER BY created_at DESC, id DESC
LIMIT 51;
CREATE INDEX CONCURRENTLY orders_tenant_status_created_id
ON orders (tenant_id, status, created_at, id);
```
`IN` lists on a leading column (`status IN ('paid','refunded')`) can force a Sort node
because rows from different status values interleave. If `EXPLAIN` shows a Sort over many
rows, either drop `status` from the index prefix (filter after the index scan) or accept
the sort when the filtered set is small. Measure both.
Low-selectivity filters with a selective sort: if 95% of rows match the filter, an index
on `(tenant_id, created_at, id)` plus a filter step is usually as fast.
## 7. Sorting by a mutable column (`updated_at`)
Rows whose sort key changes while a client pages will jump past or behind the cursor:
duplicates or skips. Mitigations:
- Prefer immutable keys (`created_at`, `id`) for general listing.
- For sync-style "changes since" feeds, page by `(updated_at, id)` ascending and let the
client re-request from its last cursor; seeing a row twice is fine if clients upsert.
- For a stable snapshot, freeze an upper bound in the cursor:
`AND updated_at <= $snapshot` with `snapshot` set when the first page was served.
## 8. Counts without full scans (PostgreSQL)
```sql
-- capped count: "1000+" in the UI, bounded cost
SELECT count(*) FROM (
SELECT 1 FROM orders WHERE tenant_id = $1 AND status = $2 LIMIT 1001
) t;
-- whole-table estimate from statistics (refreshed by ANALYZE / autovacuum)
SELECT reltuples::bigint AS estimate FROM pg_class WHERE oid = 'public.orders'::regclass;
-- filtered estimate: parse the planner's row estimate
EXPLAIN (FORMAT JSON) SELECT 1 FROM orders WHERE tenant_id = $1 AND status = $2;
-- read [0]["Plan"]["Plan Rows"]
```
Label estimates as approximate in the API (`"totalEstimate": 48200`), never as `total`.
## 9. Search engines and relevance sorting
Relevance scores are not stable keys in SQL. Use the engine's native deep-paging cursor:
Elasticsearch/OpenSearch `search_after` with a tiebreaker sort field (plus a point in time
for consistency), Meilisearch and Typesense offset limits with capped depth. Do not
emulate it with `OFFSET` in the database.
## 10. Cursor encoding (Python and TypeScript)
```python
import base64, hashlib, hmac, json
SECRET = settings.CURSOR_SECRET.encode()
def encode_cursor(payload: dict) -> str:
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
sig = hmac.new(SECRET, body, hashlib.sha256).digest()[:16]
return base64.urlsafe_b64encode(body + sig).rstrip(b"=").decode()
def decode_cursor(token: str) -> dict:
raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
body, sig = raw[:-16], raw[-16:]
if not hmac.compare_digest(sig, hmac.new(SECRET, body, hashlib.sha256).digest()[:16]):
raise InvalidCursor()
return json.loads(body)
# payload example: {"v":1,"k":["2026-10-20T09:12:44.123456Z","ord_8f3k"],"s":"-createdAt","f":"<hash of filters>"}
```
```ts
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.CURSOR_SECRET!;
export function encodeCursor(payload: object): string {
const body = Buffer.from(JSON.stringify(payload));
const sig = createHmac("sha256", SECRET).update(body).digest().subarray(0, 16);
return Buffer.concat([body, sig]).toString("base64url");
}
export function decodeCursor(token: string): any {
const raw = Buffer.from(token, "base64url");
const body = raw.subarray(0, -16), sig = raw.subarray(-16);
const want = createHmac("sha256", SECRET).update(body).digest().subarray(0, 16);
if (sig.length !== 16 || !timingSafeEqual(sig, want)) throw new Error("invalid cursor");
return JSON.parse(body.toString());
}
```
Serialize timestamps at full database precision (microseconds in PostgreSQL). Truncating
to milliseconds makes `(created_at, id) < (...)` skip or repeat rows that share a
millisecond.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.