droid
CREATE INDEX and Equality Lookup
Indexes and the Optimizer · Stage 2 of 11

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.

Not run yet · 12 tests
Concepts in this stage
  • 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
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 page
B+Tree
Reaching the disk
Pager
Database file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

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:

0
04
00
6e
32
35
30
fa
00
00
00
  • 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
One way to lay out the entry for the row with id 250 and name 'n250'. The whole entry is the cell's key, and there is no payload. The layout is yours; the order entries sort in is not.

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:

The index on name: find 'n250', read its table key
internal · page 52
[n2,n4]
leaf · page 51
[n1,n10,n100]
next → page 53
leaf · page 53 · read
[n2,n25,n250]
next → page 54
leaf · page 54
[n4,n5,n9]
next → none
The table: descend to key 250
internal · page 2 · read
[200,400]
leaf · page 3
[1,100]
next → page 4
leaf · page 4 · read
[200,250,300]
next → page 5
leaf · page 5
[400,500]
next → none
A sketch, keys abridged. Two descents, four pages, instead of reading every leaf. Strings sort as bytes, so 'n250' comes after 'n25' and before 'n4'.

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

1

Create the index

Check everything first, then allocate, fill, and record.

  1. 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. 2.Allocate the index's root: an empty leaf, of a node type that is not a table's.
  3. 3.Scan the table once. For each row, make the entry: the row's value in the column, then the row's key.
  4. 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. 5.Write the index's record into the catalog: name, table, column, root page.
2

Plan an equality

The table's own key still comes first; an index is the next choice; a full scan the last.

  1. 1.Split the WHERE into conjuncts, as before.
  2. 2.If a conjunct bounds the table's key, use the table's IndexScan, as before.
  3. 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. 4.Whatever is left of the WHERE goes into a Filter above the scan.
3

Run an index IndexScan

Two descents for every row it hands up.

  1. 1.Descend the index to the first entry whose value is the key.
  2. 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. 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.

InputOutput
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 4a 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

shell
$droid --db shop.db
Welcome 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';
id
3
(1 rows)
[EXEC] rows_examined=3
>create index idx on s (name);
Create index statement executed successfully
>select id from s where name = 'cy';
id
3
(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
>.exit
exiting.. 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 KeyOrder with one implementation for table keys and one for index entries keeps one tree implementation; Ordering::then composes value and key.
  • Zig: pass a comparison function as a comptime parameter 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 it
    backfill-then-lookup
  • indexes lists the index; index check finds one entry per row
    index-check-and-list
  • With 4 pages of cache, a lookup through the index reads a handful of pages; a scan reads them all
    fewer-reads-than-scan
  • An index survives reopening, and lookups still go through it
    index-persists
  • Unknown table or column, a name already used, a missing index, a value too long to index
    index-errors
  • DROP INDEX: lookups go back to a full scan, and the name is free again
    drop-index
  • DROP INDEX puts the index's pages on the free list
    drop-index-frees-pages
  • DROP TABLE drops its indexes, and their names are free again
    drop-table-drops-indexes
  • CREATE INDEX inside a rolled-back transaction leaves no index
    create-index-rollback
  • VACUUM moves the index's pages and ADD COLUMN leaves it alone; lookups still work
    vacuum-and-alter-keep-index
  • explain: an equality on an indexed column is an IndexScan with index=; the table key still wins
    index-plans
  • index and on are keywords; ast and explain of CREATE INDEX and DROP INDEX
    tokenize-ast-explain