SQL and PostgreSQL path · Shaping the data · Topic 7 of 13

Pagination: OFFSET versus keyset

Not started

The obvious way to page through results is LIMIT 20 OFFSET 40 for page 3. It works, and for the first few pages it is fine. But OFFSET means "produce these rows and throw them away", so the cost grows with the page number, and because it counts positions, any insert or delete before your page shifts what you see. Keyset pagination (sometimes called seek pagination) asks for "the next 20 after the last row I showed" instead.

Why it matters: OFFSET pages get slower the deeper you go and can repeat or skip rows; keyset pages stay fast and stable.

  1. 1OFFSET still does the work

    Rows skipped by OFFSET are computed and thrown away, so page 10,000 costs as much as reading 10,000 pages.

  2. 2Keyset continues after a key

    WHERE (created_at, id) > (last seen values) ORDER BY created_at, id LIMIT n seeks straight to the next page through an index.

  3. 3A unique, total order

    Sort by something unique (add the primary key as a tie-breaker), or rows with equal sort values can repeat or vanish between pages.

  4. 4The trade-off

    Keyset gives next and previous, not "jump to page 37"; that is usually fine for feeds and APIs.

1

How it works

The explanation, with examples you can run.

OFFSET, measured

On 500,000 orders with an index on (created_at, id):

EXPLAIN ANALYZESELECT id, created_at FROM ordersORDER BY created_at, idLIMIT 20 OFFSET 400000;
Limit  (cost=12160.42..12161.03 rows=20 width=16) (actual time=20.032..20.033 rows=20 loops=1)  ->  Index Only Scan using orders_created_id_idx on orders  (cost=0.42..15200.42 rows=500000 width=16) (actual time=0.034..14.033 rows=400020 loops=1)        Heap Fetches: 0Execution Time: 20.039 ms

The index avoids a sort, but it still reads 400,020 entries to return 20. Without the index it was worse: a full sort of the table that spilled to disk (61 ms here). Page 20,000 of a busy table is reached by crawlers and scripts more often than by people, and each such request does this much work.

Keyset: continue after the last row

Remember the sort key of the last row on the page and ask for rows after it. A row comparison does this neatly, and matches the index column by column:

EXPLAIN ANALYZESELECT id, created_at FROM ordersWHERE (created_at, id) > ('2025-10-10 18:40:00+01', 400000)ORDER BY created_at, idLIMIT 20;
Limit  (cost=0.42..1.08 rows=20 width=16) (actual time=0.014..0.015 rows=20 loops=1)  ->  Index Only Scan using orders_created_id_idx on orders  (cost=0.42..3049.08 rows=92609 width=16) (actual time=0.014..0.014 rows=20 loops=1)        Index Cond: (ROW(created_at, id) > ROW('2025-10-10 18:40:00+01'::timestamp with time zone, 400000))        Heap Fetches: 0Execution Time: 0.026 ms

The Index Cond lets the scan start at the boundary and read exactly 20 entries, however deep the page. Three rules make it correct:

  • The ORDER BY must be unique; add the primary key as a last column.
  • The index must match the ORDER BY (same columns, same order; for newest first, compare with < and order DESC on both).
  • The cursor you give the client is the last row's sort values, usually encoded as an opaque string.

Seeing both behaviours

This plain-JS simulation pages through 100 rows sorted by (createdAt, id), inserts a row at the front between pages, and counts the rows each method has to examine.

⌘/Ctrl + Enter
OutputNot run yet

Press Run to see what your code prints, in order.

Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.

The cost of keyset pagination is that you cannot jump to an arbitrary page number. For infinite scroll, "load more" and API cursors that is no loss; for a numbered admin table with a few pages, OFFSET is fine.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.Why does SELECT ... ORDER BY created_at, id LIMIT 20 OFFSET 400000 get slow even with an index on (created_at, id)?
  2. 2.A user is on page 1 of a newest-first list when a new item is published. They click "next" (OFFSET 20). What do they see?
  3. 3.Why add id to ORDER BY created_at for keyset pagination?

0 of 3 right so far. Get all three to finish this topic.