droid
LIMIT and OFFSET
Querying · Stage 7 of 11

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.

Not run yet · 10 tests
Concepts in this stage
  • 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
Where this stage sits in the engine
Talking to the user
REPL
Sessions
Printer
Server
Wire protocol
Understanding SQL
Lexer
Parser
Validator
Answering a query
Planner
Executor
Keeping changes safe
TransactionsWrite-ahead log
Undo log
Lock manager
Read views
Storing rows
Row formatSlotted pageB+Tree
Reaching the disk
PagerDatabase file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

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:

select id from t where id % 10 = 0 limit 3
new
Limit (count=3, offset=0)
asks no more after the third
↑ 3 rows: 10, 20, 30
Project (cols=[id])
↑ 3 rows
Filter (id % 10 = 0)
↑ 30 rows, then never asked again
ran
SeqScan (table=t)
select id from t order by name limit 5
new
Limit (count=5, offset=0)
↑ 5 rows
Project (cols=[id])
↑ 5 rows
reads all first
Sort (key=name ASC)
↑ all 1000 rows, before the first goes up
ran
SeqScan (table=t)
Left: the Limit stops asking, so the scan stops at row 30 (rows_examined=30). Right: the Sort must read everything before it hands up its first row, so the Limit saves nothing below it (rows_examined=1000).
Query over ids 1 to 1000rows_examinedWhy
limit 55the scan stops when asked for no more
limit 10 offset 100110skipped rows are still read
where id % 10 = 0 limit 330the Filter reads until three rows pass
order by name limit 51000a Sort reads everything before its first row
order by id limit 55no Sort: the scan is already in key order
limit 00nothing 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

1

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. 1.When asked for a row, if the count left is zero, answer 'no more rows' without asking the child for anything.
  2. 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. 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. 4.Invariant: the child is asked for at most offset + count rows, and never again once the count reaches zero.
2

Place it in the plan

Limit applies to the finished result, so it goes above everything.

  1. 1.Build the plan as before: scan, Filter, Sort, Project.
  2. 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. 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.

InputOutput
select id from t limit 5; over 1000 rowsids 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

shell
$droid --db big.db
Welcome to droid!
>select id, name from t limit 3;
id | name
1 | n919
2 | n838
3 | n757
(3 rows)
[EXEC] rows_examined=3
>select id from t where id % 100 = 0 limit 2 offset 1;
id
200
300
(2 rows)
[EXEC] rows_examined=300
>select id from t order by name limit 2;
id
1000
679
(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)
>.exit
exiting.. 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 5 examines 6.
  • Asking the child when the count is 0. limit 0 must examine nothing.
  • OFFSET counted against the limit. limit 10 offset 100 hands 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 500 stops failing (see the next bullet but one).
  • Accepting an expression. LIMIT takes a number alone; limit 1 + 1 is 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 0 examine every row.
  • Projecting before the Limit asks. Evaluating every row's select list up front makes limit 3 fail 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_t and compare with 4294967295 before narrowing.
  • Rust: if your operators implement Iterator, skip(offset).take(count) is exactly this operator, and take stops calling next on its source.
  • Zig: keep the counters as u32 and check the parsed value with std.math.cast before storing it.

Checklist

One line per thing the tests check. The tags name the test cases.

  • Without ORDER BY, LIMIT keeps the smallest keys
    no-order-is-pk-order
  • LIMIT and OFFSET pick a window of the result
    limit-rows
  • An OFFSET past the last row, and LIMIT 0, give no row and no error
    offset-past-end
  • LIMIT and OFFSET take a number alone, from 0 to 4294967295: 01505 otherwise
    limit-invalid
  • A row the Limit never asks for cannot fail the statement
    lazy-errors
  • The rows a Limit skips are asked for from the Project, so they are evaluated
    skipped-rows-projected
  • A Limit stops the scan: rows_examined counts only what it asked for
    short-circuit
  • Below a Sort the scan reads everything
    sort-blocks-short-circuit
  • LIMIT is a Limit node at the top of the plan
    explain-limit
  • limit and offset are keywords; ast prints a Limit line
    tokenize-ast-limit