Top-N and Ordered Index Scans
Two ways not to sort everything: keep only the best N, or read rows already in order
Replace a Sort under a Limit by a TopN that keeps only count plus offset rows and never spills, and drop the Sort entirely when an index already hands up rows in the ORDER BY's order, stopping after the rows the Limit needs.
- ORDER BY ... LIMIT n needs the best n rows, not all of them sorted
- A bounded heap keeps the best n in memory however large the input
- A B+Tree's leaves are already sorted: reading them in order is a sort for free
- An ordered scan under a LIMIT stops early, so it reads n rows, not the table
- Whether the index or a sort wins is a cost question, and the LIMIT is part of the answer
Why this stage exists
"The ten most recent orders", "the top five scores": ORDER BY ... LIMIT n is one of the most common query shapes there is, and so far droid answers it the expensive way. It sorts every row, spilling to disk when they do not fit, and then throws away all but a few.
There are two cheaper answers, and this stage builds both.
Keep only the best n
To return the 5 smallest names, you never need more than 5 rows in hand. Read the rows one by one, keep the best 5 seen so far, and when a new row beats the worst of them, swap it in. A heap ordered with the worst kept row on top makes "is this better than the worst?" a single comparison:
With OFFSET the TopN keeps count + offset rows, since the Limit still has to skip the first ones: limit 5 offset 10 is a TopN of 15. Ties need the same care as in the Sort stage: two rows with the same name must come out in the order they arrived, so a later row never displaces an earlier equal one.
This changes two kinds of earlier output. A plan that had a Sort under a Limit now prints a TopN there, and a statement that sorted under a Limit now prints sort_runs=0 where it printed 1. The earlier cases that checked those lines are retired in this stage.
Read rows already in order
An index on name is the rows sorted by name, NULLs first, ties by key, exactly the order ORDER BY name asks for. If the plan reads that index, the Sort has nothing to do, and the plan drops it:
The rule is precise. For ORDER BY c, the scan must read an index whose columns before c are fixed by = (so they cannot reorder anything) and whose next column is c. An index on (city, age) serves where city = 'x' order by age; it does not serve order by age alone.
DESC is the one case left out. Reading name descending means walking the leaves right to left, and this engine's leaves link only left to right. So ORDER BY name DESC keeps its TopN or Sort.
When the index loses
An ordered index scan looks every row up in the table, one descent each. For three rows that is nothing. For the whole table it is 2000 descents, far more than one sequential read and a sort. With statistics, the cost model of the cost-model stage makes the call: the ordered scan costs Di + E × Dt with E the rows the Limit needs, and the SeqScan with its Sort costs P.
The algorithm in words
Top-N
Keep the best n rows seen so far.
- 1.Use a TopN when a Sort hands its rows to a Limit through at most a Project, the count is at least 1, and n = count + offset is at most --sort-memory.
- 2.Keep a heap of at most n rows, ordered so that the worst kept row is on top. Break ties by arrival, as the Sort does: a later row never displaces an earlier equal one.
- 3.For each row: while the heap holds fewer than n, add it; otherwise, if it is better than the worst, replace the worst.
- 4.When the input ends, hand the kept rows up in order.
Ordered index scan
Use the order the index already has.
- 1.For ORDER BY c ASC, one key, look at the chosen scan: if it reads an index whose columns before c are fixed by = and whose next column is c, drop the Sort.
- 2.With no bound at all, an index whose first column is c may be chosen for the order alone: it reads every entry, NULLs first. Without statistics it is chosen whenever no key bound or index bound chose another scan (the index created first on c, index-only when it covers the statement).
- 3.Under a Limit, the scan stops when the Limit has its rows.
- 4.DESC keeps the Sort: the leaves link one way.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, section 63.
| Input | Output |
|---|---|
explain select name from t order by name limit 5 offset 10; | Limit (count=5, offset=10), Project (cols=[name]), TopN (key=name ASC, n=15), SeqScan (table=t) |
explain select id, n from t order by name limit 3; (index on name) | Limit (count=3, offset=0), Project (cols=[id, n]), IndexScan (table=t, index=by_name, order=asc) |
explain select id, n from t order by name desc limit 3; | the same with TopN (key=name DESC, n=3) over SeqScan (table=t) |
| a statement with a TopN | [EXEC] rows_examined=<n> sort_runs=0 |
| a statement whose ORDER BY the index provides | an [EXEC] line with no sort_runs |
What you should see when done
$droid --db top.db --sort-memory 2Welcome to droid!>create table t (id int, name varchar(10));Create table statement executed successfully>insert into t (id, name) values (1, 'cy');Insert statement executed successfully (1 rows)>insert into t (id, name) values (2, 'ada');Insert statement executed successfully (1 rows)>insert into t (id, name) values (3, 'bob');Insert statement executed successfully (1 rows)>select name from t order by name limit 2;nameadabob(2 rows)[EXEC] rows_examined=3 sort_runs=0>select name from t order by name;nameadabobcy(3 rows)[EXEC] rows_examined=3 sort_runs=2>.exitexiting.. good bye!
Common bugs in this stage
- A heap with the best row on top. You need to know the worst kept row, the one to evict.
- Ties broken differently from the Sort. A later equal row must not displace an earlier one, or LIMIT returns different rows than the full sort.
- Forgetting the offset.
limit 5 offset 10needs 15 rows. - Dropping the Sort for DESC. The leaves go one way.
- An ordered full index scan that skips NULLs. ORDER BY puts NULLs first, and so must the scan.
- Reading the whole index under a LIMIT. Stop when the Limit stops asking.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: an array of n row slots with sift-up and sift-down is a heap in thirty lines; store each row's arrival number to break ties.
- C++:
std::priority_queuewith a comparator on (keys, arrival) keeps the worst on top. - Rust:
BinaryHeapis a max-heap: order by (keys, arrival) and the worst kept row is on top. - Zig:
std.PriorityQueuewith a compare function on (keys, arrival).
Checklist
One line per thing the tests check. The tags name the test cases.
- ORDER BY ... LIMIT 5 keeps five rows, so it never spills; the same ORDER BY without LIMIT doestopn-no-spill
- LIMIT with OFFSET keeps count plus offset rows, and returns the rows a full sort wouldtopn-with-offset
- With an index on name, ORDER BY name reads the index in order and sorts nothingindex-order-no-sort
- ORDER BY name DESC still sorts: leaves are read one waydesc-keeps-sort
- With statistics, the index order wins under a small LIMIT and loses for the whole tablecost-picks-sort-or-index
- explain: TopN with n = count + offset; an ordered IndexScan with order=asc and no Sorttopn-and-order-plans