Query Planner and Volcano Executor
Turn a statement into a tree of small operators that hand rows up one at a time, and print that tree with explain
Split SELECT into a planner that builds a tree of plan nodes (SeqScan, Filter, Insert) and an executor that pulls rows from the top node until there are none, without changing a single result, and show the plan with explain.
- The plan: a tree of operators, each doing one job, built before any row is read
- The Volcano model: every operator answers one question, 'next row, please', and asks its child the same
- Pulling, not pushing: rows travel up one at a time, and an operator never holds the whole table
- Reading rows and choosing rows are different jobs: a SeqScan reads, a Filter above it chooses
- explain: print the plan and run nothing
Why this stage exists
SELECT works. It checks the statement, walks the table with a cursor, tests each row against the WHERE clause, and prints what matches. All of that probably lives in one function, and it will not stay small. index-scan wants a second way to read rows. The Querying part adds sorting, limits, grouping, and expressions. Each of them, added to that one function, makes every other harder to change.
Every relational database solves this the same way. First decide what to do, as a tree of small operators, each with one job. Then run the tree. This stage makes that split. It adds no feature: every result stays exactly as it was, and the tests of the earlier stages keep passing. What it adds is a way to see the tree, explain.
What changes: a statement becomes a plan
select name from users where name = 'bob' becomes two operators. A SeqScan reads every row of the table, in id order, and knows nothing about the WHERE clause. A Filter sits above it, holds the condition name = 'bob', and lets only the rows that satisfy it through. The select list is not an operator here: the executor prints the columns it asks for from each row the top node hands it.
This is the Volcano model, named after the research system that described it. Every operator has the same three operations: open, next, close. Asking the top node for its next row makes it ask its child, and so on down to the node that reads the table:
Rows travel up one at a time. The Filter never holds the table, and neither does the executor: each row is printed before the next is read. That is what lets a plan answer a query over a table far bigger than memory, and what lets a node above stop early, as a LIMIT will in a later part.
The planner builds the tree before any row is read, so everything that can be wrong with the statement is found first. A statement with an unknown column, or a value of the wrong type, has no plan: its error is the only line, exactly as in the previous stages.
What about WHERE id = 2?
In this stage, nothing special. id is the key the tree is sorted by, so a search could go straight to row 2 and read one row. The planner here does not: where id = 2 is a Filter over a SeqScan, exactly like where name = 'bob', and reads every row to keep one.
That is deliberate. One scan and one Filter are all the machinery this stage needs, and the tree of operators is the idea it teaches. index-scan adds a second scan, one that uses the key, and teaches the planner when to choose it. It replaces the plans of where id = ..., and it says which cases of this stage it retires.
explain
explain <statement> builds the plan, prints it, and stops. It reads no row and changes nothing: an explained INSERT inserts nothing, and an explained INSERT of an id that already exists still prints its plan, because only running it would find the duplicate.
The first line is the statement as you typed it after explain. Keywords keep their case, and spaces inside stay as they were. Only the spaces around it and the final ; go. Then comes one line per node, top first, each level four spaces deeper than the one above:
>explain select name from users where name = 'bob';[PLAN] select name from users where name = 'bob'[PLAN] └── Filter (name = 'bob')[PLAN] └── SeqScan (table=users)
The node that reads the table is always the deepest line. The Filter shows its condition the way ast shows a WHERE: the column, the operator, and the value, strings in single quotes and numbers bare.
The algorithm in words
Build the plan
The planner turns a checked statement into a tree of nodes. It reads no rows.
- 1.Check the statement first, exactly as running it would: the parser's errors, then the table, the columns, the value's type and range, in the order of the WHERE stage. A statement that fails has no plan.
- 2.An INSERT is one node, Insert, with no child.
- 3.A SELECT starts with a SeqScan, the node that reads every row.
- 4.If it has a WHERE clause, put a Filter holding the condition above the SeqScan. In this stage that is true of every WHERE, on id too.
- 5.Invariant: the plan is a chain, each node with at most one child, and the node that reads the table is always at the bottom.
Run the plan
Each node can be opened, asked for its next row, and closed. The executor only ever talks to the top node.
- 1.A SeqScan, opened, puts a cursor on the first row of the table. Asked for its next row, it returns the row under the cursor and advances; at the end, it answers 'no more rows'.
- 2.A Filter, opened, opens its child. Asked for its next row, it asks its child for rows until one satisfies its condition, and returns that one. When its child has no more rows, neither has the Filter.
- 3.The executor prints the header, opens the top node, asks it for rows until it has no more, printing and counting each, closes it, and prints the footer.
- 4.Invariant: the results are the same as before this stage, row for row. Only the shape of the code changed.
Print the plan
explain runs the planner and prints what it built, and never runs the executor.
- 1.Take the statement after the word explain. Drop the spaces around it and its final semicolon. Nothing else changes.
- 2.Print [PLAN], a space and that statement.
- 3.Print one line per node, from the top of the tree down: [PLAN], two spaces, the connector └── with a space after it, and the node. Each node below its parent is indented four more spaces.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, section 12.
| Input | Output |
|---|---|
explain select ... from users; | [PLAN] <statement>, then [PLAN] └── SeqScan (table=users) |
explain select ... from users where <column> <op> <value>; | [PLAN] <statement>, [PLAN] └── Filter (<column> <op> <value>), [PLAN] └── SeqScan (table=users) |
explain insert into users ...; | [PLAN] <statement>, then [PLAN] └── Insert (table=users) |
explain with nothing after it | [ERROR:00901] <message> |
explain of a statement that could not run | that statement's error, and no [PLAN] line |
<statement> is the statement after explain, without the spaces around it and its final ;. The connector is └──, the box-drawing characters U+2514 and U+2500 twice, followed by a space.
What you should see when done
$droid --db users.dbWelcome to droid!>insert into users (id, name, email) values (1, 'alice', 'alice@example.com');Insert statement executed successfully>insert into users (id, name, email) values (2, 'bob', 'bob@example.com');Insert statement executed successfully>explain select * from users;[PLAN] select * from users[PLAN] └── SeqScan (table=users)>explain select name from users where name = 'bob';[PLAN] select name from users where name = 'bob'[PLAN] └── Filter (name = 'bob')[PLAN] └── SeqScan (table=users)>select name from users where name = 'bob';namebob(1 rows)>explain select * from users where id = 2;[PLAN] select * from users where id = 2[PLAN] └── Filter (id = 2)[PLAN] └── SeqScan (table=users)>explain insert into users (id, name, email) values (3, 'cy', 'cy@example.com');[PLAN] insert into users (id, name, email) values (3, 'cy', 'cy@example.com')[PLAN] └── Insert (table=users)>select id from users;id12(2 rows)>explain select * from users where email = 7;[ERROR:00403] Column email expects a string>explain[ERROR:00901] explain needs a statement>.exitexiting.. good bye!
The explained INSERT of row 3 added nothing: the SELECT after it still finds rows 1 and 2.
Common bugs in this stage
- Results change. The refactor drops the last row, repeats one, or loses the order. Run the suites of the earlier stages: they must all still pass.
- The Filter returns after one try. Asked for its next row, it asks its child once and returns nothing if that row does not match, so the scan stops at the first row that fails. A Filter keeps asking until a row matches or its child has none left.
- The scan starts over. A SeqScan that does not keep its cursor between calls returns the first row forever. The cursor is the node's state, kept from open to close.
- explain runs the statement. The plan is printed and the INSERT happens anyway. explain builds the plan and stops.
- explain prints the plan of a broken statement. Check first: a statement with an error has no plan, and the error is the only line.
- The statement is reprinted from the AST.
SELECT * FROM userstyped in capitals comes back in small letters, or the spaces change. Print the text as typed. - The wrong connector or indent.
└──is three characters, and each level adds exactly four spaces.|--, tabs, or two spaces per level fail the tests. - The Filter in the scan. Testing the condition inside the SeqScan works today, and leaves
index-scannowhere to put a Filter above a different scan. Keep the jobs apart.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: a node can be a struct holding three function pointers, open, next and close, plus a pointer to its own state and to its child.
nextreturns whether it produced a row, and fills in a row the caller passed. - C++: an abstract base class with virtual
open,nextandclose, and each node owning its child through astd::unique_ptr. - Rust: a trait with
fn next(&mut self) -> Option<Row>, and nodes holding their child asBox<dyn Trait>. It is the same shape as Rust's ownIterator. - Zig: a struct holding a pointer to its state and a table of function pointers, the pattern
std.mem.Allocatoruses, or a tagged union of node kinds with aswitchinnext. - All languages:
└and─are three bytes each in UTF-8. Write them as a string, not as single characters.
Checklist
One line per thing the tests check. The tags name the test cases.
- A SELECT without WHERE plans a SeqScan, whatever its select listexplain-select-seqscan
- A WHERE on id plans a Filter over a SeqScan, until the index-scan stageexplain-select-where-id
- A WHERE on name or email plans a Filter over a SeqScan, with the value as the parser prints itexplain-select-seqscan-filter
- An INSERT plans a single Insert nodeexplain-insert
- The first plan line is the statement as typed, without the spaces around it and its semicolonexplain-statement-as-typed
- explain runs nothing: an explained INSERT adds no rowexplain-executes-nothing
- explain of a statement that does not parse prints the parser's error and no planexplain-syntax-error
- explain alone is 00901; names, types and ranges are checked as running would check themexplain-checks-the-statement
- The plan and the result of a WHERE on id, on name, and of a full scan agreewhere-id-end-to-end · seqscan-filter-end-to-end · seqscan-full-scan-end-to-end