Index Scan on the Primary Key
Let a WHERE on id go down the tree to its first row, and count the rows every scan reads
Add an IndexScan node that answers WHERE id = k, id > k and id < k by searching the tree instead of reading every row, have the planner choose it for any WHERE on id, and print how many rows each SELECT examined.
- An index scan: use the order the tree already keeps to start where the answer starts and stop where it ends
- The same rows, less work: a plan changes how rows are found, never which rows are found
- Access paths: a SeqScan and an IndexScan are two ways to produce the same rows, and the planner picks one
- Measuring work: rows_examined counts the rows a scan hands up, so the difference between two plans is visible
- Ranges: after the first key, the leaf chain gives the rest in order
Why this stage exists
The tree has been sorted by id since btree-search, and btree find has used that since, reading one path from the root to a leaf. SELECT never did. where id = 20 has been a Filter over a SeqScan: read every row, keep one. On 24 rows nobody notices. On a million rows it is the difference between reading three pages and reading all of them.
This stage gives the planner a second way to read the table, the IndexScan, and makes it the plan for every WHERE on id. The rows printed do not change, not one. What changes is how many rows are read to find them, and a new line makes that visible: [EXEC] rows_examined=<n>.
What changes: the scan starts where the answer starts
The tree is sorted by id, and its leaves are chained in that order. So the rows with id > 14 are all together: they start at one position in one leaf and run to the end of the chain. An IndexScan goes down the tree to that position, the way btree find does, then reads along the leaves:
The three conditions differ only in where they start and when they stop:
| Condition | Starts at | Stops |
|---|---|---|
id = k | the position of k, found by searching the tree | after one row, or at once if k is not there |
id > k | the first key greater than k, found by searching the tree | at the end of the table |
id < k | the first row of the table | at the first key that is not less than k |
id < k starts at the beginning, like a SeqScan, but it stops as soon as the keys pass k instead of reading on to the end. Because the rows come in key order, nothing after that key can match.
The IndexScan hands up only rows that satisfy its condition, so there is no Filter above it. For name and email nothing changes: the tree is not sorted by them, and a Filter over a SeqScan is still the only way to answer.
The plan
explain shows the new node. For a key it names the key; for a range, the condition:
>explain select * from users where id = 20;[PLAN] select * from users where id = 20[PLAN] └── IndexScan (table=users, key=20)>explain select id from users where id > 14;[PLAN] select id from users where id > 14[PLAN] └── IndexScan (table=users, range: id > 14)
The volcano model makes this a small change. An IndexScan has the same open, next and close as any other node. The executor, the Filter and both printers do not change at all. Only the planner learns to choose it.
Counting the work
After every SELECT's result, the program now prints how many rows the scan at the bottom of the plan read and handed up: [EXEC] rows_examined=<n>. In table mode it follows the (n rows) footer; in CSV mode, the last row, or the header line when no row matched.
- A SeqScan hands up every row, so it examines the whole table. A Filter above it may keep one row, and 24 were still examined.
- An IndexScan examines only the rows it hands up: 1 for a key that is there, 0 for one that is not, and the rows of the range for
>and<. - To know that
id < 3is over, the scan must look at key 3. It does not hand that row up, so it does not count it:id < 3over rows 1 to 24 examines 2.
The counter is what the tests use to tell the two plans apart. The rows printed are the same either way; only the count shows which plan ran.
The algorithm in words
Choose the scan
The planner now has two ways to read the table.
- 1.A WHERE on id, with =, < or >, gets an IndexScan holding the operator and the key. Nothing goes above it: every row it hands up already satisfies the condition.
- 2.A WHERE on name or email gets a Filter over a SeqScan, as before: the tree is not ordered by them.
- 3.A SELECT without WHERE gets a SeqScan.
- 4.Invariant: for any statement, the rows printed are exactly the ones the plan of the previous stage printed, in the same order.
Run an IndexScan
Every IndexScan finds a starting point in the tree, then reads forward along the leaves until its condition stops holding.
- 1.For id = k: search the tree for k, as btree find does. If it is there, hand up that row, once. Either way the scan is then over.
- 2.For id > k: search for the position of the first key greater than k. Hand up rows from there, following the leaf links, to the end of the table.
- 3.For id < k: start at the first row of the table. Hand up rows while the key is less than k, and stop at the first key that is not.
- 4.Count each row you hand up. The key that tells you a range is over is looked at, not handed up, and not counted.
- 5.Invariant: the scan reads one path down the tree, then only the leaves that hold its rows, plus at most one more key.
Count what was examined
Every scan counts the rows it hands up, and SELECT prints the count at the end.
- 1.A SeqScan hands up every row of the table, so its count is the table's size, whatever the Filter above it keeps.
- 2.An IndexScan's count is the number of rows it handed up.
- 3.After the SELECT's result, the footer in table mode and the last line in CSV mode, print [EXEC] rows_examined=<n>.
- 4.A statement that fails, an INSERT, and explain print no counter.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, sections 12 and 13.
| Input | Output |
|---|---|
explain select ... where id = <k>; | [PLAN] <statement>, then [PLAN] └── IndexScan (table=users, key=<k>) |
explain select ... where id > <k>; or id < <k> | [PLAN] <statement>, then [PLAN] └── IndexScan (table=users, range: id > <k>) or range: id < <k> |
explain select ... where name ... or email ... | a Filter over a SeqScan, as before |
| any SELECT that runs | its result, then [EXEC] rows_examined=<n> |
an INSERT, explain, a statement that fails | no [EXEC] line |
What you should see when done
The file already holds rows 1 to 24, inserted in order in an earlier session.
$droid --db users.dbWelcome to droid!>explain select * from users where id = 20;[PLAN] select * from users where id = 20[PLAN] └── IndexScan (table=users, key=20)>select * from users where id = 20;id | name | email20 | user20 | [email protected](1 rows)[EXEC] rows_examined=1>explain select id from users where id > 14;[PLAN] select id from users where id > 14[PLAN] └── IndexScan (table=users, range: id > 14)>select id from users where id > 14;id15161718192021222324(10 rows)[EXEC] rows_examined=10>select id from users where name = 'user20';id20(1 rows)[EXEC] rows_examined=24>select id from users where id < 3;id12(2 rows)[EXEC] rows_examined=2>.exitexiting.. good bye!
Row 20 found by its id examined 1 row. Found by its name, 24: same row, same output, and the counter is the only line that shows the difference.
Common bugs in this stage
- Still filtering. The plan says IndexScan, but the node reads every row and checks each one. The rows are right and the counter says 24. An IndexScan starts at its first row and stops after its last.
- Stopping one leaf early. A range reads to the end of the leaf where it started and stops, instead of following the link.
id > 14returns 15 and 16. Follow the leaf chain. - Counting the row that ends the range.
id < 3examines 3 instead of 2. Look at the key to decide, and count only the rows handed up. - Starting
id > kat k. The search for k lands on k when it is there, and>must not include it. Start at the first key greater than k. - Equal keys going left. The descent must use the same rule as
btree find: a key equal to a separator is on its right. Otherwiseid = 13searches the wrong leaf and finds nothing. - A Filter left above the IndexScan. It does no harm to the rows and fails
explain, which shows one node. - An IndexScan for name. The tree is sorted by id only. A WHERE on name through the tree finds some rows and misses others.
- The counter in the wrong place. Before the footer, missing in CSV mode, or printed after an error or after
explain.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- All languages: the search that finds the position of a key, or of the first key greater than it, is the one
btree findand INSERT already use. An IndexScan is that search plus the cursor you wrote for SELECT, started at the position the search returns. - C: the IndexScan node's state is a cursor, the operator, the key, and a flag for "done". Its
nextis a few lines, because the cursor already knows how to cross leaves. - C++: a second subclass of the node base class, beside
SeqScan, with the same three virtual methods. The planner returns one or the other through the samestd::unique_ptrto the base. - Rust: an
IndexScanstruct implementing the same trait asSeqScan, holding a cursor. If your cursor borrows the pager, the scan needs the same lifetime. - Zig: one more arm in the tagged union of nodes, or one more struct behind the same function table; the
switchinnextgains a case and nothing else changes.
Checklist
One line per thing the tests check. The tags name the test cases.
explainof a WHERE id = k shows one node, IndexScan (table=users, key=k)explain-index-scan-equalsexplainof a WHERE id > k or id < k shows IndexScan (table=users, range: id > k)explain-index-scan-ranges- A WHERE on name or email is still a Filter over a SeqScanexplain-other-columns-still-filter
- A SeqScan examines every row, whatever the Filter keepsexec-counter-full-scan
- WHERE id = k examines 1 row, or 0 when k is not thereexec-counter-equals
- A range examines only the rows in it, and not the key that ends itexec-counter-ranges
- The IndexScan compares keys unsignedexec-counter-unsigned
- On an empty table every scan examines 0exec-counter-empty-table
- In a tree of depth 4, an IndexScan examines 10 rows where a full scan examines 150exec-counter-deep-tree
- The counter follows the last row in CSV modeexec-counter-csv
- No counter after an INSERT, an error or explainexec-counter-only-after-a-select
- A WHERE on id plans an IndexScan and prints its row, examining 1index-scan-end-to-end
- The IndexScan searches a tree read back from the fileindex-scan-after-restart