LIMIT and OFFSET
Stop early: the payoff of pulling one row at a time
Parse LIMIT and OFFSET, plan a Limit node at the top of every plan that has one, and make it stop asking for rows once it has handed up its last, so that nothing below it reads a row it does not need.
- Limit is an operator that stops asking: the Volcano model's laziness made visible
- OFFSET is not free: skipped rows are produced and thrown away
- A blocking operator below a Limit reads everything anyway
- Without ORDER BY the order is still defined here: ascending key order
- Rows that are never asked for are never evaluated, so they cannot fail
Why this stage exists
Since volcano-executor, every operator has worked the same way: asked for a row, it asks the operator below it, does its bit, and hands one row up. So far that was a matter of style; every query read the whole table anyway. LIMIT is where the style pays. select * from t limit 5 needs five rows, and an operator that simply stops asking after the fifth means the scan below it reads five rows out of a million and is never asked for a sixth.
That is the whole stage: a small operator whose most important behaviour is what it does not do. rows_examined, the counter from the index-scan stage, makes it visible.
Stopping early, and when you cannot
limit 5 over a thousand rows examines 5. Add a WHERE that keeps every tenth row, and limit 3 examines 30: the Filter reads rows until it has passed three, and stops being asked. OFFSET is not free: limit 10 offset 100 reads the hundred rows it skips, so it examines 110.
A Sort changes everything. order by name limit 5 must find the five smallest names, and the last row the scan reads might be one of them, so the Sort reads all thousand before it can hand up the first. The Limit still stops after five, but the work below it is already done:
| Query over ids 1 to 1000 | rows_examined | Why |
|---|---|---|
limit 5 | 5 | the scan stops when asked for no more |
limit 10 offset 100 | 110 | skipped rows are still read |
where id % 10 = 0 limit 3 | 30 | the Filter reads until three rows pass |
order by name limit 5 | 1000 | a Sort reads everything before its first row |
order by id limit 5 | 5 | no Sort: the scan is already in key order |
limit 0 | 0 | nothing is ever asked for |
The last useful observation: order by id limit 5 examines only 5, because the planner left the Sort out (the previous stages) and the scan delivers key order by itself. Choosing a plan that can stop early is what the Indexes and the Optimizer part is about.
Order without ORDER BY
In SQL, rows without ORDER BY come back in no promised order, and "LIMIT without ORDER BY returns an arbitrary subset" is the standard warning. In this engine the order is promised: a scan reads the B+Tree from its first leaf to its last, so rows come in ascending key order, and the contract says so. limit 2 is always the two smallest keys the WHERE keeps. Real databases make no such promise, because a parallel scan, a hash join or a different index can change the order; write ORDER BY when you need one.
Laziness, seen through errors
An expression is evaluated only for rows that are asked for. In a table where only row 500 has b = 0, select id, 10 / b from t limit 3 evaluates the division three times and succeeds, while limit 600 reaches row 500 and fails with 01501. Put a Sort below the Limit, order by 10 / b limit 1, and the Sort evaluates its key for every row, so it fails too. And OFFSET is not a way around it: the rows a Limit skips are still asked for, from the Project below it, so limit 1 offset 500 projects row 500 and fails on it, whereas limit 2 offset 400 never reaches it.
At the other end, limit 0 asks for nothing at all, so even a Sort below it never starts: order by name limit 0 examines no row and prints sort_runs=0.
The algorithm in words
Run the Limit operator
Limit keeps two numbers: how many rows it still has to skip, and how many it may still hand up.
- 1.When asked for a row, if the count left is zero, answer 'no more rows' without asking the child for anything.
- 2.Otherwise, while there are rows left to skip, ask the child for a row and throw it away. If the child runs out, answer 'no more rows'.
- 3.Ask the child for one more row. If it has none, answer 'no more rows'. Otherwise lower the count left by one and hand the row up.
- 4.Invariant: the child is asked for at most offset + count rows, and never again once the count reaches zero.
Place it in the plan
Limit applies to the finished result, so it goes above everything.
- 1.Build the plan as before: scan, Filter, Sort, Project.
- 2.If the statement has a LIMIT, put a Limit node on top, with its count and its offset, 0 when OFFSET is not written.
- 3.The parser checks the token after LIMIT and after OFFSET, before any name or type check runs: one that is not a number, or a number past 4294967295, is 01505 at that token's column.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, section 46.
| Input | Output |
|---|---|
select id from t limit 5; over 1000 rows | ids 1 to 5, (5 rows), [EXEC] rows_examined=5 |
select id from t order by name limit 5; | the five smallest names' ids, [EXEC] rows_examined=1000 sort_runs=1 |
explain select id from t where id > 5 order by name limit 5 offset 2; | Limit (count=5, offset=2), Project (cols=[id]), Sort (key=name ASC), IndexScan (table=t, range: id > 5) |
explain select * from t limit 3; | Limit (count=3, offset=0) over SeqScan (table=t) |
ast select id from t order by name limit 5 offset 2; | ... Order: [name ASC], then Limit: count=5, offset=2 |
limit -1, limit abc, limit;, limit 4294967296 | [ERROR:01505] <message>, from the parser, before any other check |
limit 1 + 1, offset 5 without LIMIT, LIMIT on DELETE | [ERROR:00302] <message> |
An OFFSET past the last row and limit 0 print the header and (0 rows). The Limit node is always at the top of the plan, above the Project.
What you should see when done
$droid --db big.dbWelcome to droid!>select id, name from t limit 3;id | name1 | n9192 | n8383 | n757(3 rows)[EXEC] rows_examined=3>select id from t where id % 100 = 0 limit 2 offset 1;id200300(2 rows)[EXEC] rows_examined=300>select id from t order by name limit 2;id1000679(2 rows)[EXEC] rows_examined=1000 sort_runs=1>explain select id from t order by name limit 2;[PLAN] select id from t order by name limit 2[PLAN] └── Limit (count=2, offset=0)[PLAN] └── Project (cols=[id])[PLAN] └── Sort (key=name ASC)[PLAN] └── SeqScan (table=t)>.exitexiting.. good bye!
This is the test table: a thousand rows whose names are n and three digits, in an order that has nothing to do with the key. The first query read three rows. The second skipped one matching row and kept two, reading up to row 300. The third read every row, because of the Sort, and only then kept two.
Common bugs in this stage
- Limiting at the end. Collecting every row and printing the first five gives the right rows and
rows_examined=1000. - One row too many. A Limit that asks for the next row before checking its count reads one extra row:
limit 5examines 6. - Asking the child when the count is 0.
limit 0must examine nothing. - OFFSET counted against the limit.
limit 10 offset 100hands up 10 rows, not 0 or 90. - The Limit below the Project. The plan line is in the wrong place, and the skipped rows are no longer projected, so
limit 1 offset 500stops failing (see the next bullet but one). - Accepting an expression. LIMIT takes a number alone;
limit 1 + 1is a syntax error. - Skipping rows below the Project. The Limit is on top, so the rows it skips have been projected; a shortcut that skips them earlier hides a division by zero the contract says must fail.
- A Sort that starts before it is asked. Reading the input when the plan is built makes
limit 0examine every row. - Projecting before the Limit asks. Evaluating every row's select list up front makes
limit 3fail on row 500.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C and C++: the Limit operator is a struct with the child pointer and two counters; nothing else. Parse the number into a
uint64_tand compare with 4294967295 before narrowing. - Rust: if your operators implement
Iterator,skip(offset).take(count)is exactly this operator, andtakestops callingnexton its source. - Zig: keep the counters as
u32and check the parsed value withstd.math.castbefore storing it.
Checklist
One line per thing the tests check. The tags name the test cases.
- Without ORDER BY, LIMIT keeps the smallest keysno-order-is-pk-order
- LIMIT and OFFSET pick a window of the resultlimit-rows
- An OFFSET past the last row, and LIMIT 0, give no row and no erroroffset-past-end
- LIMIT and OFFSET take a number alone, from 0 to 4294967295: 01505 otherwiselimit-invalid
- A row the Limit never asks for cannot fail the statementlazy-errors
- The rows a Limit skips are asked for from the Project, so they are evaluatedskipped-rows-projected
- A Limit stops the scan: rows_examined counts only what it asked forshort-circuit
- Below a Sort the scan reads everythingsort-blocks-short-circuit
- LIMIT is a Limit node at the top of the planexplain-limit
- limit and offset are keywords; ast prints a Limit linetokenize-ast-limit