CREATE INDEX and Equality Lookup
Build a second B+Tree over one column, and find rows through it in two descents
Create and drop secondary indexes whose entries are a column's value followed by the row's key, fill one from an existing table, record it in the catalog, and plan an equality on an indexed column as an IndexScan that looks each row up in the table.
- A secondary index is a second B+Tree over the same rows, ordered by another column
- An entry is the value followed by the table key, with no payload, so entries are unique even when values repeat
- An index points at a key, never at a page: rows move, their keys do not
- An index lookup is two descents, one in the index and one in the table, and the second one is what costs
- Indexes live in the catalog, so DDL on them joins transactions, recovery, DROP TABLE and VACUUM
Why this stage exists
A table's B+Tree answers one question fast: which row has this key? Every other question, where name = 'n250' included, reads every leaf. On a table of fifty leaves that is fifty page reads to find one row.
An index answers the same kind of question for another column. It is a second B+Tree, kept beside the table, ordered by that column. Find the value in it, and it tells you where the row is.
The interesting part is what "where the row is" means.
What an entry holds
The obvious answer is a page and slot: "the row is in page 37, slot 4". It is also wrong, for an engine like this one. Rows live inside the table's B+Tree, and they move: a leaf split sends half of them to a new page, a merge sends them back, VACUUM renumbers pages, a long row spills to overflow pages. An index full of page numbers would be wrong after the next insert.
What does not move is the row's key. So an index entry is the indexed value followed by the row's table key, and nothing else:
- lengthbytes 0–1 (2 bytes) · the value is 4 bytes long
- 'n250'bytes 2–5 (4 bytes) · the indexed value
- table keybytes 6–9 (4 bytes) · id 250, low byte first
Putting the table key inside the entry's key, rather than after it as a payload, solves duplicates before they appear. Two rows with the same name make two entries that differ in their table key, so no two entries are ever equal, an index never needs a list of rows per value, and deleting one row later removes exactly one entry.
Entries compare left to right: by value first (INT as unsigned numbers, VARCHAR as bytes with a prefix before the longer string, NULL before everything), then by table key.
Two descents
A lookup through an index goes down two trees:
That second descent is the price of an index that stores keys, and it is paid once per matching row. For one match it is nothing. For half the table it is far more than a full scan, and the optimizer stages of this part are about knowing which case you are in. For now the rule is simple: an equality on an indexed column uses the index.
The [EXEC] line shows both descents' work. rows_examined counts the rows the scan handed up, as always: 1, not 500. A new field, index_lookups, counts the table lookups made through entries. They are equal in this stage; a later stage makes the second one zero.
Indexes are catalog objects
An index is recorded in the catalog, next to its table: its name, table, column and root page. That one decision makes the rest of the engine handle it for free:
- Reopening finds it in the catalog.
- Transactions: CREATE INDEX writes pages and a catalog record, so a ROLLBACK takes the whole index back, and a crash after COMMIT keeps it.
- DROP TABLE looks up the table's indexes and frees their trees too; DROP INDEX frees one.
- VACUUM already updates every pointer to a page it moves; the index's root in the catalog is one more.
Index names are global, like table names in their own name space: create index idx on a (x) and create index idx on b (y) cannot both exist.
The algorithm in words
Create the index
Check everything first, then allocate, fill, and record.
- 1.Check the table (00401), the column (00402), that no index of any table already has the name (01602), and that no row's value in the column is longer than 255 bytes (01604).
- 2.Allocate the index's root: an empty leaf, of a node type that is not a table's.
- 3.Scan the table once. For each row, make the entry: the row's value in the column, then the row's key.
- 4.Insert each entry into the index tree, splitting nodes by the same rules as a table's tree. Entries compare by value first (numbers as numbers, strings as bytes, NULL first), then by key.
- 5.Write the index's record into the catalog: name, table, column, root page.
Plan an equality
The table's own key still comes first; an index is the next choice; a full scan the last.
- 1.Split the WHERE into conjuncts, as before.
- 2.If a conjunct bounds the table's key, use the table's IndexScan, as before.
- 3.Otherwise take the first conjunct that is an indexed column = a literal of its type, and plan an IndexScan on that column's index with that value as its key.
- 4.Whatever is left of the WHERE goes into a Filter above the scan.
Run an index IndexScan
Two descents for every row it hands up.
- 1.Descend the index to the first entry whose value is the key.
- 2.For each entry with that value, take the table key out of it, descend the table to that key, and hand the row up. Count it in rows_examined and in index_lookups.
- 3.Stop at the first entry with a different value; it is not a row and is not counted.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, section 54.
| Input | Output |
|---|---|
create index idx on t (name); | Create index statement executed successfully |
drop index idx; | Drop index statement executed successfully |
indexes | [INDEX] Indexes: 1, then [INDEX] idx on t (name) root=<page> |
index check idx | [INDEX] Check idx: OK entries=500 |
select id from t where name = 'n250'; | the row, then [EXEC] rows_examined=1 index_lookups=1 |
explain select * from s where name = 'bob'; | [PLAN] └── IndexScan (table=s, index=idx, key='bob') |
explain select * from s where id = 4 and name = 'bob'; | Filter (name = 'bob') over IndexScan (table=s, key=4): the table key wins |
explain create index idx on s (name); | [PLAN] └── CreateIndex (index=idx, table=s) |
tokenize create index idx on t (name) | [KEYWORD_CREATE - create] [KEYWORD_INDEX - index] [IDENTIFIER - idx] [KEYWORD_ON - on] [IDENTIFIER - t] [SYMBOL - (] [IDENTIFIER - name] [SYMBOL - )] |
ast create index idx on t (name); | Statement: CREATE INDEX, Index: idx, Table: t, Columns: [name] |
ast drop index idx; | Statement: DROP INDEX, Index: idx |
explain drop index idx; | [PLAN] └── DropIndex (index=idx) |
500 rows of about 420 bytes, reopened with --cache-pages 4 | a lookup through the index reads at most 14 pages (page 0 and the catalog included); where pad = 'y' reads more than 80 |
| an index name already used | [ERROR:01602] <message> |
DROP INDEX or index check of a missing name | [ERROR:01603] <message> |
| a value of more than 255 bytes to index | [ERROR:01604] <message> |
index and on are keywords. Only = on an indexed column uses the index in this stage: name > 'b' is still a Filter over a SeqScan.
What you should see when done
$droid --db shop.dbWelcome to droid!>create table s (id int, name varchar(20), n int);Create table statement executed successfully>insert into s (id, name, n) values (1, 'ada', 3);Insert statement executed successfully (1 rows)>insert into s (id, name, n) values (2, 'bob', 5);Insert statement executed successfully (1 rows)>insert into s (id, name, n) values (3, 'cy', 3);Insert statement executed successfully (1 rows)>select id from s where name = 'cy';id3(1 rows)[EXEC] rows_examined=3>create index idx on s (name);Create index statement executed successfully>select id from s where name = 'cy';id3(1 rows)[EXEC] rows_examined=1 index_lookups=1>explain select id from s where name = 'cy';[PLAN] select id from s where name = 'cy'[PLAN] └── Project (cols=[id])[PLAN] └── IndexScan (table=s, index=idx, key='cy')>index check idx[INDEX] Check idx: OK entries=3>.exitexiting.. good bye!
Common bugs in this stage
- Storing a page and slot in the entry. It works until the first split moves the row. Store the table key.
- Putting the table key in a payload instead of the entry's key. Then two rows with the same value make two equal keys, and the tree's duplicate check rejects the second. The next stage's tests will find it, but so will any table where a value repeats.
- Comparing VARCHAR values as C strings or with a locale. Compare bytes, the shorter prefix first, so the order is the same in every language.
- Counting the stopping entry. The first entry with a different value ends the scan; it is not a row.
- Forgetting the catalog. An index that lives only in memory vanishes on reopen and survives a ROLLBACK it should not.
- DROP TABLE leaving the index behind. Its pages leak, and its name stays taken.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: your B+Tree code compares keys somewhere; make that comparison a function pointer chosen by the node type, so the same split and search code serves tables and indexes.
- C++: template the tree on a key type with a comparator, or pass a comparator object; avoid copying the tree code.
- Rust: a
trait KeyOrderwith one implementation for table keys and one for index entries keeps one tree implementation;Ordering::thencomposes value and key. - Zig: pass a comparison function as a
comptimeparameter to the tree, or store one in the tree handle.
Checklist
One line per thing the tests check. The tags name the test cases.
- CREATE INDEX over 500 rows, then an equality lookup reads one row through itbackfill-then-lookup
- indexes lists the index; index check finds one entry per rowindex-check-and-list
- With 4 pages of cache, a lookup through the index reads a handful of pages; a scan reads them allfewer-reads-than-scan
- An index survives reopening, and lookups still go through itindex-persists
- Unknown table or column, a name already used, a missing index, a value too long to indexindex-errors
- DROP INDEX: lookups go back to a full scan, and the name is free againdrop-index
- DROP INDEX puts the index's pages on the free listdrop-index-frees-pages
- DROP TABLE drops its indexes, and their names are free againdrop-table-drops-indexes
- CREATE INDEX inside a rolled-back transaction leaves no indexcreate-index-rollback
- VACUUM moves the index's pages and ADD COLUMN leaves it alone; lookups still workvacuum-and-alter-keep-index
- explain: an equality on an indexed column is an IndexScan with index=; the table key still winsindex-plans
- index and on are keywords; ast and explain of CREATE INDEX and DROP INDEXtokenize-ast-explain