open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 01 · 05

Sorting and limiting: ORDER BY, LIMIT, and keyset pagination

ORDER BY + LIMIT can be answered by an index with no sort. But OFFSET 100000 still reads 100000 rows first — the deep-pagination cliff. Keyset pagination fixes it.

SQL Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A feed paginated with ORDER BY created_at DESC LIMIT 20 OFFSET ?. Page 1 was instant. Page 5,000 — OFFSET 100000 — took 1.4 seconds, because the database physically read and discarded 100,000 rows just to hand back the next 20. The deeper users scrolled, the slower it got, until infinite scroll quietly broke past page 4,000. The page size never changed; the offset did. This is the OFFSET cliff, and the fix — keyset pagination — keeps every page equally fast.

ORDER BY: direction, NULLs, collation, expressions

ORDER BY sorts the projected result (step 7 from lesson 1, so it can see SELECT aliases). The details that bite:

  • Direction and NULL placement. ASC is the default; DESC reverses. Independently, NULLS FIRST / NULLS LAST controls where NULLs land. The default is NULLS LAST for ASC and NULLS FIRST for DESC — so flipping to DESC moves NULLs to the top unless you say otherwise. A “most recent first” list with nullable timestamps can surface all the NULLs before any real row, which is rarely intended; pin it with ORDER BY created_at DESC NULLS LAST.
  • Collation decides text ordering. Under C collation, uppercase sorts before lowercase by byte value (Z before a); under a locale like en_US, sorting is case-insensitive-ish and locale-aware. Collation also affects which LIKE predicates an index can serve — a subtlety the databases indexes chapter covers.
  • Expression sort. You can sort by a computed value: ORDER BY price * qty DESC, or ORDER BY lower(name).

Together these three details interact: when you flip direction, NULL placement changes; when you sort by an expression, the index may no longer serve the sort; when you use a non-C collation, LIKE-anchored indexes behave differently. Getting any one of them wrong produces either incorrect ordering or an unintended full sort.

-- newest first, but keep unknown timestamps at the bottom, not the top
SELECT id, title, created_at
FROM events
ORDER BY created_at DESC NULLS LAST, id DESC
LIMIT 20;

Top-N without a sort: when the index does the work

ORDER BY … LIMIT n asks for the top n rows in some order. The naive execution sorts the entire table then takes the first n — O(n log n) over everything. But if an index already stores rows in the requested order, Postgres can walk the index and stop after n rows — no sort node at all, O(n) in the limit, not the table size.

The condition: the index’s column order and direction must match the ORDER BY. An index on events(created_at) lets ORDER BY created_at DESC LIMIT 20 read the last 20 leaf entries and stop. EXPLAIN shows this as an Index Scan with no Sort node above it — the senior tell that your top-N is cheap. (The mechanics of index-ordered scans live in the databases indexes and execution-plans chapters; here the takeaway is that ORDER BY + LIMIT backed by a matching index is the cheapest top-N you can write.)

The OFFSET cliff

LIMIT n OFFSET m returns n rows after skipping m. The trap is in “skipping”: the database has no shortcut to row number m. It must generate the first m rows in order and discard them, then return the next n. OFFSET 100000 LIMIT 20 does the full work of producing 100,020 ordered rows and throws 100,000 away. Cost grows linearly with the page number, so the last pages of a deep list are catastrophically slow even though every page shows 20 rows.

Keyset (seek) pagination: constant cost at any depth

The fix is to stop counting rows to skip and instead remember where the last page ended — a cursor made of the sort key. Each request asks for “rows after this cursor”, which is a WHERE the index can seek on, so the database jumps straight to the spot and reads only the page.

The cursor must include a unique tiebreaker (typically the primary key) so the order is total and no row is skipped or duplicated when sort values tie. Postgres supports the clean row-comparison form:

-- first page
SELECT id, title, created_at
FROM events
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- next page: pass the LAST row's (created_at, id) as the cursor
SELECT id, title, created_at
FROM events
WHERE (created_at, id) < ('2026-03-01 10:00:00+00', 84213)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Backed by a composite index on (created_at DESC, id DESC), the WHERE (created_at, id) < (…) is a single index seek, then 20 sequential leaf reads — O(limit), identical cost for page 2 and page 50,000. The databases indexes chapter and the canonical use-the-index-luke.com pagination guide go deeper, but the rule is simple: for deep or infinite pagination, use keyset, never OFFSET. OFFSET is fine only for shallow, bounded page counts (a UI that never goes past page 10).

Common mistake

The subtle keyset bug is forgetting the tiebreaker. If you paginate on created_at alone and two rows share a timestamp, the boundary between pages is ambiguous: a row can be shown twice or skipped entirely as the cursor crosses the tie. Always append a unique column (id) to both the ORDER BY and the cursor comparison, and use the row-comparison form (created_at, id) < (…) so the comparison is lexicographic and exact. Mixing sort directions (e.g. created_at DESC, id ASC) breaks the single-< row-comparison trick — keep the tiebreaker’s direction consistent with the row comparison or split it into explicit OR-clauses.

Quiz

Why does LIMIT 20 OFFSET 100000 get slower the deeper the page, even though it only returns 20 rows?

Quiz

Which keyset query correctly fetches the next page after the last row had (created_at, id) = (T, 84213), sorted newest first?

Complete the analogy

Fill in the blank: instead of counting and skipping rows, _______ pagination remembers the last row's sort key as a cursor and seeks straight to the next page via an index — so every page costs the same regardless of depth.

Recall before you leave
  1. 01
    Explain the OFFSET deep-pagination cliff in one sentence and why LIMIT alone doesn't have it.
  2. 02
    Write the keyset-pagination pattern for 'next page, newest first' and state the index it needs.
  3. 03
    What does flipping ORDER BY to DESC do to NULL placement, and how do you control it?
Recap

ORDER BY sorts the projected result and exposes three details worth memorizing: DESC flips NULL placement (default NULLS FIRST for DESC), so pin it with NULLS LAST; collation decides text order and even which LIKE an index can serve; and you can sort by expressions. A top-N query (ORDER BY … LIMIT n) is cheapest when an index already stores rows in the requested order — Postgres walks the index and stops after n rows, no sort node. The headline failure is the OFFSET cliff: LIMIT n OFFSET m must produce and throw away the first m ordered rows, so cost grows linearly with the page number and deep pages crawl. The fix is keyset (seek) pagination — replace OFFSET m with a WHERE (created_at, id) < (cursor) over a composite index (created_at DESC, id DESC), including a unique tiebreaker so no row is skipped or doubled at a tie. Every page then costs O(limit) regardless of depth. Use OFFSET only for shallow, bounded paging; for infinite scroll or deep lists, keyset every time. Now when you see a paginated endpoint slowing down as users scroll deeper — check the query: if it says OFFSET, that’s the cliff. Replace the offset with a cursor from the last row’s sort key, add the tiebreaker, and the latency becomes flat.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.