Build1 publisher2 min readPublished
Postgres walks all 110,659 pages of a 1.2 GB table to answer OFFSET 9999980 LIMIT 20
One dev.to benchmark on 10 million rows measures deep pagination as a full read and discard. A PostgreSQL B-tree stores keys and not ranks, so counting from the start is the only way to reach the 9,999,981st row.
The Engineer · Build desk

What happened
- A query with OFFSET 9999980 LIMIT 20 touched 110,659 buffer pages and 865 MB of a 1.2 GB table to hand back 20 rows, and EXPLAIN reported all of it.
- In the plan, the Limit node returned 20 rows while its Index Scan child on orders_pkey produced 10,000,000, with execution time at 1,172 ms.
- OFFSET time tracked depth linearly, ten times the depth for ten times the time, while the keyset cursor stayed between 0.016 and 0.027 ms across a 100,000x change in depth.
- The benchmark ran 10 million generated rows on PostgreSQL 17.11 under Docker on a Ryzen 7 5700X with 78 GB of RAM, with shared_buffers left at the default 128 MB.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- cost Serving the last page costs 499,999 rows of tuple construction for every row delivered, and that bill is set by the page number, not by the twenty rows the client asked for.
- constraint At about 6.8 times the configured 128 MB shared_buffers, the read set will not sit in the buffer pool, so a bigger cache or a faster disk moves the constant and leaves the curve where it is.
- decision Because the cursor is a different complexity class, switching to keyset pagination is a schema and API decision made before launch: the client has to be handed a stable sort key to come back with.
- exposure Any endpoint that accepts a page number from the caller lets the caller pick how deep the walk goes, and cost rises in step with the number they send.
The counting happens in the Limit node, one tuple at a time. Limit takes rows from its child, keeps a running count, and drops everything below the 9,999,981st [1]. The post puts most of the run in that discard path, with the Index Scan node alone at 934 ms, about 80 percent of execution time [5][5].
The page count resolves back into the physical table. Buffers reported `shared read=110659`, and at 8 KB a page that is 864.5 MB [4][7][1]. It decomposes as well: the primary key index is roughly 27,000 pages and the heap roughly 83,000, which sum to about 110,000 [6]. Both were walked end to end so the plan could emit 20 rows at `width=16`, or 320 bytes [3][7].
For 865 MB to be the number on your table, your projection has to look like this one. The scan returns `created_at`, which the primary key index does not contain [21]. Dropping the heap's 83,000 pages needs a projection that fits inside the index being scanned, and even then the walk still covers about 27,000 index pages, roughly 211 MB [3]. The post does not measure that case.
Descending by rank would need subtree counts. Order-statistic trees and ranked B-trees store exactly those, and the post is explicit that PostgreSQL's B-tree is not one of them [13]. Its leaves are chained together in key order, so walking is the operation on offer [24]. MVCC closes the other route. Visibility is not in the index, and checking it generally means visiting the heap, with the visibility map skipping that only when a page is known all-visible [14]. A subtree counter would count index entries, and OFFSET needs the count of rows visible to your transaction [15].
The author's own correction is the part to read before defending an existing endpoint. The earlier explanation offered was that OFFSET "skips ahead, and skipping that far costs something", which the post calls "wrong in the one way that matters" [16][17]. The same care shows in the numbers. The cursor timings sit at 0.016 to 0.027 ms, close to measurement resolution, so the roughly 49,000x ratio is not precise and the claim is only that the column is flat [10]. The five-run table put this query at 791 ms where the instrumented plan put it at 1,172 ms, a gap the post attributes to EXPLAIN (ANALYZE, BUFFERS) timestamping every node and charging more the more rows pass through [18].
What to watch
- Part 2 of the post, which the author says handles created_at repeating every 100 rows, the tie case a keyset cursor has to survive.
- A repeat with shared_buffers raised above the default 128 MB, which would separate I/O time from tuple-construction time in the OFFSET curve.
- A run on a table larger than the test box's 78 GB of RAM, where the walked pages become real disk reads.