Skip to content
EL
Contents

Pagination and large result sets

Keyset pagination

ConceptCore4 min read15 min hands-on#sql#indexing#query-planning

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:

  1. The order must be unique. created_at alone can tie; add the primary key as a tie-breaker, in both the ORDER BY and the cursor.
  2. The index must match. Equality columns first, then the sort columns: (org_id, created_at, id). PostgreSQL can read it backward for DESC, DESC.
  3. 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:

PageOFFSET: pages touched / timeKeyset: pages touched / time
100240 / 0.26 ms6 / 0.013 ms
1,0002,375 / 2.8 ms7 / 0.011 ms
4,99911,859 / 45 ms6 / 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?

Loading interactive…

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 OFFSET still fits, ideally with a cap on depth.
  • Sort by arbitrary columns without an index for each sort order a user can choose.

Check yourself

  1. 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.

  2. 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.

  3. 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. 1.
    PostgreSQL documentation: 9.25 Row and Array Comparisons
    Primary· §9.25.5 Row Constructor Comparison

    Row comparisons with < compare elements left to right, stopping at the first unequal pair.

  2. 2.
    PostgreSQL documentation: 11.4 Indexes and ORDER BY
    Primary· §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. 3.
    PostgreSQL documentation: 7.6 LIMIT and OFFSET
    Primary· §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. 1.
    Use The Index, Luke!: We need tool support for keyset pagination
    ReferenceMarkus 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.