Stop Paginating With OFFSET: Keyset Pagination and the Deep-Page Cliff

LIMIT 20 OFFSET 40 is how almost everyone paginates, and on the first few pages it is perfectly fine. The problem is what OFFSET actually asks the database to do: produce every row in sorted order up to and including the offset, then throw the offset rows away and return the rest. Page one discards nothing. Page 5,000 at 20 rows per page tells the database to generate 100,000 rows in order and discard 99,980 of them, on every single request. Cost scales with how deep the page is, so pagination that feels instant in testing quietly falls off a cliff on the deep pages that infinite scroll, API consumers, and crawlers reach constantly.
The fix is keyset pagination (also called seek or cursor pagination), and the win is dramatic: instead of counting past rows you do not want, you remember where the last page ended and seek straight to the next one, so every page costs the same no matter how deep you are. This post shows why OFFSET gets slower with depth even with a perfect index, how keyset pagination works, the composite-key detail that makes it correct, and the one real tradeoff you are accepting.
TL;DR
OFFSET nmakes the database walk and discardnrows before it can return your page, so query time grows with page depth. An index does not fix it; the rows still have to be walked.- Keyset pagination replaces
OFFSETwith aWHEREclause on the last row's sort key:WHERE (sort_key) > :last ORDER BY sort_key LIMIT n. With an index on the sort key, every page is roughly the same cost regardless of depth. - Order by a unique key (or a
(column, id)tuple as a tiebreaker) or you will skip or duplicate rows at page boundaries. - The tradeoff: keyset gives you next/previous, not "jump to page 47." It is ideal for infinite scroll and APIs, and a poor fit for a UI that needs numbered pages.
- Do not over-correct. On small tables or shallow pagination,
OFFSETis fine. Reach for keyset when pages get deep or the table gets large.
Prerequisites
- A SQL database (examples are PostgreSQL, but the idea applies to MySQL and others)
- A table you paginate with
ORDER BY ... LIMIT ... OFFSET ... - Comfort reading
EXPLAIN (ANALYZE) - An index on the column(s) you sort by
Why OFFSET gets slower the deeper you page
The database cannot skip to the millionth row of a sorted result without first establishing which rows come before it. Even with an index on the ORDER BY column, OFFSET 1000000 means the executor walks a million index entries (and, unless the scan is index-only, fetches their heap rows) purely to count them off, then starts returning yours. Without a usable index it is worse: a full sort of the matching set before anything is discarded.
LIMIT 20 OFFSET 100000, ordered by created_at
scan in sorted order ────────────────────────────▶
[row 1][row 2] ... [row 100000][row 100001 ... 100020]
\_________ walked and DISCARDED _________/ \__ returned __/
100,000 rows of pure waste 20 rows
The 20 rows you keep are cheap. The 100,000 you discard are the whole cost, and they get re-discarded on every request for that page. This is why "add an index" is not the fix people expect: the index makes the walk ordered, but you are still walking.
Seeing the cliff
Put numbers on it with EXPLAIN (ANALYZE) on a table of a few million rows, indexed on created_at. Page one is instant; a deep page is not, and keyset is instant at any depth.
Look at actual rows on the index scan: the deep OFFSET reads 1,000,020 rows to return 20, while keyset reads 20. The timings are illustrative, but the shape is the mechanism, not luck: OFFSET work grows with depth, keyset work does not.
Keyset pagination
The idea is to stop describing a page by "how many rows to skip" and start describing it by "where the last page ended." You order by a key, return a page, and remember the last row's key. The next page asks for rows past that key:
-- first page
SELECT id, created_at, title
FROM events
ORDER BY created_at DESC
LIMIT 20;
-- next page: seek past the last row you showed (created_at = :last_seen)
SELECT id, created_at, title
FROM events
WHERE created_at < :last_seen
ORDER BY created_at DESC
LIMIT 20;
Because there is a WHERE on the indexed sort column, the database uses the index to jump straight to the starting position and reads only the 20 rows it returns. Page 1 and page 50,000 do the same amount of work. That is the entire trick.
Make the sort key unique, or you will skip rows
There is a correctness catch that trips people up. created_at is almost never unique: many rows can share a timestamp. If two rows at a page boundary have the same created_at, a plain created_at < :last_seen can skip or duplicate them. The fix is to order by a tuple that is guaranteed unique, normally the sort column plus the primary key, and seek on the whole tuple with a row-value comparison:
-- stable total order: (created_at, id); seek on the tuple
SELECT id, created_at, title
FROM events
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
PostgreSQL compares row values left to right, and a composite index on (created_at, id) serves this directly. Now the ordering is a total order with no ties, so no boundary row is ever skipped or repeated. In practice you hand the client an opaque cursor, usually the last (created_at, id) encoded as a base64 token, and it passes that back for the next page instead of a page number.
Keyset only works if the ordering is deterministic and total. Order by something unique, or append a unique tiebreaker like the primary key. And make sure an index covers the exact ORDER BY you seek on ((created_at, id) here); without it, keyset loses its whole advantage and you are back to scanning.
The tradeoff, and when OFFSET is fine
Keyset is not a free lunch, and pretending otherwise is how you pick the wrong tool.
- No random page access. You get next and previous, not "jump to page 200." There is no cheap way to land on an arbitrary numbered page, because you do not know the key that page starts at without walking there. If your UI shows
1 2 3 ... 200and users click around, keyset does not fit; classic numbered pagination needsOFFSET(or a different design). - Total counts are still expensive. Keyset does not give you "page X of Y" for free. If you need an exact total, that is a separate
count(*), and on a big table you may want an estimate instead. - Small or shallow cases do not need it. On a table of a few thousand rows, or an admin screen nobody pages past screen three,
OFFSETis simpler and completely fine. Do not add cursor plumbing to a list that never gets deep.
The sweet spot for keyset is exactly where OFFSET hurts: infinite scroll, "load more" feeds, public APIs whose consumers page through everything, and any endpoint a crawler will walk to the end. Those are deep-pagination workloads by nature, and they rarely need to jump to an arbitrary page.
How to adopt it
- Find the deep-pagination endpoints. Look for
ORDER BY ... LIMIT ... OFFSET ...on large tables, especially anything feeding infinite scroll or a public API. - Pick a total ordering. Choose your sort column plus a unique tiebreaker (usually the primary key), and add or confirm a composite index on exactly that.
- Switch skip to seek. Replace
OFFSETwith aWHERE (sort_cols) </> (:cursor)on that tuple, keepingORDER BYaligned with the index. - Return a cursor, not a page number. Encode the last row's key as an opaque token the client sends back for the next page.
- Measure at depth. Compare
EXPLAIN (ANALYZE)on a deep page before and after, and watchactual rowscollapse fromoffset + limitdown tolimit.
Want to practice the EXPLAIN and ORDER BY mechanics behind this hands-on? The PostgreSQL Terminal Simulator runs EXPLAIN before and after an index in the browser, and the SQL Terminal Simulator lets you write and run the queries against a sample schema.
Wrapping up
OFFSET is not broken, it is just doing exactly what it says: skipping rows by counting past them, which costs more the deeper you go. On shallow pages nobody notices; on the deep pages that real traffic reaches, that linear cost is a latency cliff you cannot index your way out of. Keyset pagination trades random page access, which most feeds and APIs never needed, for pages that cost the same at any depth. Find your deep-pagination endpoints, give them a unique ordering with an index to match, and seek instead of skip. The reward is pagination that stays fast at row one and row ten million alike.
We earn commissions when you shop through the links below.
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
SMTPfast
Developer-first email API
Send transactional and marketing email through a clean REST API. Detailed logs, webhooks, and embeddable signup forms in one dashboard.
QuizAPI
Developer-first quiz platform
Build, generate, and embed quizzes with a powerful REST API. AI-powered question generation and live multiplayer.
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?
Related Posts
Also worth your time on this topic
Stop Using Random UUIDs as Primary Keys: uuidv7() Lands in PostgreSQL 18
Random UUIDv4 primary keys quietly wreck insert speed and bloat indexes on large tables. PostgreSQL 18 ships a native time-ordered uuidv7() that keeps the upsides of UUIDs without the B-tree penalty. Here are the numbers and how to adopt it.
Redis Caching Strategies for Scalable Applications
Implement production-ready caching patterns with Redis to dramatically improve application performance and scalability.
70 minutes
Designing Rate Limiting for APIs: Algorithms, Patterns, and Implementation
Pick the right rate limiting algorithm for your traffic shape, build it on shared atomic state, and ship it with the response headers, failure modes, and monitoring that keep both your API and your clients working.
2-3 hours