Top-N Heapsort and LIMIT Plans #
An admin page lists the 50 most recent orders with ORDER BY placed_at DESC LIMIT 50, and its plan shows Sort Method: top-N heapsort Memory: 32kB over 40 million rows — fast and cheap. Page 2,000 of the same list, LIMIT 50 OFFSET 99950, shows Sort Method: external merge Disk: 1804208kB. The only change is the offset, and it turned a bounded sort into a full one.
Sort nodes in general are analysed in sort and hash node analysis; the relationship to window queries is in window functions and top-N plans. This page covers the bounded sort itself.
The Algorithmic Condition #
When a Limit node sits directly above a Sort — possibly with nodes between them that cannot change the number or order of rows — the planner passes the sort a bound: offset + count. With a bound N, the executor uses one of two strategies:
- Top-N heapsort. While reading input, keep at most N rows in a binary heap ordered so the worst kept row is at the top. Each new row is compared with that worst row and either discarded or swapped in. Work is
O(input × log N), memory isO(N), and the input is never fully sorted. Chosen when N rows fit inwork_mem. - Full sort. When N rows would not fit in
work_mem— a very large bound — the executor falls back to an ordinary sort, in memory or external, and returns the first N rows.
Two things break the bound:
- Large offsets.
LIMIT 50 OFFSET 99950gives N = 100,000. Wide rows at that N exceedwork_mem, and the sort becomes a full external sort of the entire input. - Intervening nodes. A
Limitabove an aggregate, a window function, aUnique, or a join cannot pass its bound to a sort below those nodes, because they change row counts. The sort then sorts everything.
Whether a sort is used at all competes with an ordered index scan, which needs no sort and stops after N rows — ideal unless the rows needed are rare in index order, the trap covered in startup cost vs total cost under LIMIT.
Annotated EXPLAIN Evidence #
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, customer_id, total, placed_at
FROM orders WHERE status = 'shipped'
ORDER BY total DESC
LIMIT 50;
Limit (actual time=8204.1..8204.2 rows=50 loops=1)
-> Sort (actual time=8204.1..8204.1 rows=50 loops=1)
Sort Key: total DESC
Sort Method: top-N heapsort Memory: 32kB -- bounded: only 50 rows kept
-> Seq Scan on orders (actual rows=40012004 loops=1)
Filter: (status = 'shipped'::text)
Execution Time: 8204.6 ms
-- cost is the scan; the sort itself is trivial
Deep page:
... ORDER BY total DESC LIMIT 50 OFFSET 99950;
Limit (actual time=41204.8..41205.0 rows=50 loops=1)
-> Sort (actual time=41180.2..41204.2 rows=100000 loops=1)
Sort Key: total DESC
Sort Method: external merge Disk: 1804208kB -- bound too large for a heap
-> Seq Scan on orders (actual rows=40012004 loops=1)
Execution Time: 41810.6 ms
A bound lost to an intervening node:
Limit (actual rows=50 loops=1)
-> Unique (actual rows=50 loops=1)
-> Sort (actual rows=40012004 loops=1)
Sort Key: customer_id, total DESC
Sort Method: external merge Disk: 1402110kB
-- SELECT DISTINCT … LIMIT 50: the Unique node blocks the bound, so the sort is full
Step-by-Step Resolution #
-
Read
Sort Methodunder everyLimit.top-N heapsortis healthy;quicksortorexternal mergeunder aLimitmeans the bound was too large or was blocked. -
Replace deep
OFFSETwith keyset pagination. Remember the last row’s sort key and continue from it, keeping N at the page size:SELECT id, customer_id, total, placed_at FROM orders WHERE status = 'shipped' AND (total, id) < ($last_total, $last_id) ORDER BY total DESC, id DESC LIMIT 50;The implementation details for ORMs are in offset vs keyset pagination plans.
-
Add an index matching filter and order so no sort is needed at all:
CREATE INDEX CONCURRENTLY orders_shipped_total_idx ON orders (total DESC, id DESC) WHERE status = 'shipped'; -
Move the limit below blocking nodes where semantics allow. For “50 distinct customers with the highest order”, use
DISTINCT ONwith a matching order, or a subquery that limits before joining. -
Watch the width of sorted rows. The heap holds whole rows; selecting wide columns raises memory per row and lowers the bound that fits. Sort ids first and join back for the wide columns.
-
Check parallel plans. Under
Gather Merge, each worker sorts its share; top-N bounds are applied per worker as well, which keeps memory bounded.
Before and After #
-- BEFORE: OFFSET pagination, page 2,000
Limit → Sort (external merge Disk: 1804208kB) → Seq Scan (40M rows) Execution Time: 41810.6 ms
-- AFTER: keyset pagination + partial index on (total DESC, id DESC)
Limit → Index Scan using orders_shipped_total_idx Index Cond: (ROW(total, id) < ROW(…)) Execution Time: 0.21 ms
Ties and stable ordering #
Top-N results are only well defined when the sort order is unique. With ORDER BY total DESC LIMIT 50, rows tied at the 50th total may be chosen arbitrarily, and a heapsort and a full sort can pick different tied rows. Pagination then repeats or skips rows across pages. Always end the ORDER BY with a unique column — usually the primary key — both for correctness and so that keyset conditions can use a row comparison like (total, id) < (…), which a composite B-tree index serves directly.
PostgreSQL 13 added FETCH FIRST … WITH TIES, which returns all rows tied with the last one. It is useful for leaderboards but makes the bound open-ended: the sort must continue past N while ties remain, which is still cheap for heapsort but can return more rows than the page size.
Common Pitfalls #
Deep OFFSET pagination. The bound grows with the page number. Diagnostic signal: sort method changes as users page deeper. Fix: keyset pagination.
DISTINCT or GROUP BY under LIMIT. The bound cannot pass through. Diagnostic signal: full sort below Unique or an aggregate. Fix: DISTINCT ON with matching order, or limit earlier.
Wide rows in the heap. Memory per row reduces the usable bound. Diagnostic signal: heapsort falling back to external sort at modest N. Fix: sort narrow keys, then join for wide columns.
Non-unique ordering. Tied rows move between pages. Diagnostic signal: duplicates or gaps across pages. Fix: add a unique tiebreaker column.
Frequently Asked Questions #
What is top-N heapsort in PostgreSQL? #
The sort method used when a LIMIT applies directly to a sort. The executor keeps only the best offset-plus-limit rows in a heap while reading its input, so memory stays small and the input is never fully sorted.
Why does OFFSET make my query slow? #
The sort bound is offset plus limit, so a deep page must keep or sort a very large number of rows, and the rows before the offset are produced and discarded. Beyond a point the bound exceeds work_mem and the plan falls back to a full sort.
Why is my ORDER BY LIMIT query still doing a full sort? #
Either the bound is too large for a heap in work_mem, or a node between the Limit and the Sort — such as Unique, an aggregate or a join — prevents the bound from reaching the sort.
Related #
- Window Functions and Top-N Plans — parent guide: sorting for windows and limits
- Window Function Run Conditions — sibling: per-partition top-N with windows
- Covering Indexes for ORDER BY and LIMIT — removing the sort entirely