Pagination and large result sets
Keyset pagination
The idea in 30 seconds
Instead of "skip N rows", keyset pagination asks for "the next rows after the last one I showed", using the last row's sort values as a cursor. With an index on the sort columns, every page is a short index range scan, as fast at page 5,000 as at page 1, and new rows arriving at the top no longer shift the pages.
Helps to know first: B-tree indexes, Set operations and advanced expressions
On this page
Pages by value, not by position
OFFSET identifies a page by position: “rows 99,961 to 99,980”. Keyset identifies it by value: “the 20 rows that come right after the last one you saw”. The client keeps the last row’s sort values and sends them back as a cursor:
-- first page
SELECT id, created_at, payload FROM events
WHERE org_id = 7
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- next page: $2, $3 are the created_at and id of the last row of the previous page
SELECT id, created_at, payload FROM events
WHERE org_id = 7
AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 20;
(created_at, id) < ($2, $3) is the row comparison from The expression toolkit: earlier created_at, or the same created_at with a smaller id. That is exactly “comes after the cursor, in this order”.
Three rules make it work:
- The order must be unique.
created_atalone can tie; add the primary key as a tie-breaker, in both theORDER BYand the cursor. - The index must match. Equality columns first, then the sort columns:
(org_id, created_at, id). PostgreSQL can read it backward forDESC, DESC. - The cursor is the last row’s values, not a page number.
What it costs
The same one-million-row table and the same depths as the previous page, with each cursor taken from the last row of the page before:
| Page | OFFSET: pages touched / time | Keyset: pages touched / time |
|---|---|---|
| 100 | 240 / 0.26 ms | 6 / 0.013 ms |
| 1,000 | 2,375 / 2.8 ms | 7 / 0.011 ms |
| 4,999 | 11,859 / 45 ms | 6 / 0.009 ms |
Keyset does the same tiny amount of work at every depth. The plan shows why:
Limit (actual rows=20.00 loops=1)
Buffers: shared hit=6
-> Index Scan Backward using events_org_created_id on events (actual rows=20.00 loops=1)
Index Cond: ((org_id = 7) AND (ROW(created_at, id) < ROW('2024-12-31 18:36:47+00'::timestamp with time zone, 407)))
The cursor became part of the index condition. PostgreSQL descends the B-tree straight to the cursor, reads 20 entries backward, and stops.
Pages that stay put
PredictNew rows while you read
A feed shows 5 items per page, newest first. A user loads page 1: items 12, 11, 10, 9, 8. While they read, two new items, 13 and 14, arrive. They load page 2. What does LIMIT 5 OFFSET 5 show them?
9, 8, 7, 6, 5. Items 9 and 8 again. We ran it:
| Page 2 with | Shows |
|---|---|
OFFSET 5 | 9, 8, 7, 6, 5 |
| Keyset, cursor after item 8 | 7, 6, 5, 4, 3 |
The two new rows pushed everything down two positions, so position 6 is now item 9. Deletions do the opposite and silently skip rows. Keyset asks for “after item 8”, which does not move when rows arrive at the top.
What keyset cannot do
- Jump to page 731. There is no cursor for a page you have not reached. Most feeds and APIs only need next (and previous, by flipping the comparison and the sort); “jump to page” interfaces are where
OFFSETstill fits, ideally with a cap on depth. - Sort by arbitrary columns without an index for each sort order a user can choose.
Check yourself
Why must the keyset order include a unique column such as
id?Show answer
If the sort column can tie, rows with the same value have no defined order and the cursor cannot say which of them were already shown. Adding a unique tie-breaker makes the order, and the cursor, exact.
What index serves
WHERE org_id = $1 AND (created_at, id) < ($2, $3) ORDER BY created_at DESC, id DESC?Show answer
(org_id, created_at, id): the equality column first, then the sort columns. PostgreSQL scans it backward for the descending order.Why does keyset not show duplicates when new rows arrive at the top?
Show answer
It asks for rows after a specific value, not after a number of positions. New rows at the top change positions but not which rows come after the cursor.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 9.25 Row and Array ComparisonsPrimary· §9.25.5 Row Constructor Comparison
Row comparisons with < compare elements left to right, stopping at the first unequal pair.
- 2.PostgreSQL documentation: 11.4 Indexes and ORDER BYPrimary· §11.4 Indexes and ORDER BY
A B-tree can return rows in index order and can be scanned backward to satisfy DESC on all columns; an index on (x, y) serves ORDER BY x DESC, y DESC.
- 3.PostgreSQL documentation: 7.6 LIMIT and OFFSETPrimary· §7.6
Paging with LIMIT and OFFSET gives inconsistent results unless the ordering is unique and predictable.
Go further
What to read next, and why it is worth the time.
- 1.Use The Index, Luke!: We need tool support for keyset paginationReferenceMarkus Winand· location not yet verified
The best-known argument for keyset pagination, with examples for several databases and frameworks.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.