Build indexes over several columns, use one for any WHERE that bounds a prefix of its columns, enforce UNIQUE across all of them, and skip the table entirely when the index holds every column a SELECT reads.
Two ideas share this stage because they are two sides of one fact: an index entry can hold more than one value.
The first is the composite key. Queries often pin more than one column: where city = 'x' and age = 30. An index on city finds every row in the city and filters the ages; an index on (city, age) goes straight to the rows that match both.
The second is the covering index. Every lookup so far paid for two descents: one in the index, one in the table to fetch the row. If the entry already holds every column the query needs, the second descent has nothing to fetch.
Entries of an index on (city, age) sort by city first, and by age only among rows with the same city:
That ordering decides which WHERE clauses the index can serve, exactly as a phone book sorted by last name, then first name, can find "Smith" or "Smith, Anna" but not every "Anna":
| WHERE | Usable? | Why |
|---|---|---|
city = 'x' and age = 30 | yes, both columns | one value of each: one spot |
city = 'x' | yes, the first column | all of city x is one run of entries |
city = 'x' and age > 20 | yes, = then a range | inside city x, ages are in order: still one run |
city > 'm' | yes, a range on the first | the cities are in order |
age = 30 | no | age 30 is scattered across every city |
The rule: an index helps through a prefix of its columns, = on as many leading columns as the WHERE pins, then at most one range on the next. Everything after a range, and every column the prefix does not reach, goes into the Filter.
Every entry ends with the row's table key. So an index on (city, age) actually holds city, age and id, and a query that reads only those has nothing to fetch from the table:
The [EXEC] line shows it: rows_examined=10 index_lookups=0. And the pager shows it louder: on a table of wide rows, the covered query reads a handful of index pages, while the same WHERE with one more column reads the table page by page.
count(*) is the favourite trick: it reads no column, so any index whose columns cover the WHERE answers it without the table.
Covering decides how to read the chosen index, never which index to choose. That keeps the plan predictable now; weighing a covering index against a better-matching one is a cost question for the cost-model stage.
The entry is the row's values in each indexed column, in the order the index lists them, then the table key.
Walk the index's columns from the first, and stop at the first one the WHERE does not pin.
Decide once the scan is chosen.
These are the exact lines the tests check. The full definition is on the output contract page, section 58.
| Input | Output |
|---|---|
create index ca on t (city, age); | Create index statement executed successfully |
indexes | [INDEX] ca on t (city, age) root=<page> |
explain select * from t where age = 30 and city = 'x'; | IndexScan (table=t, index=ca, key=('x', 30)) |
explain select * from t where city = 'x'; | IndexScan (table=t, index=ca, key=('x')) |
explain select * from t where city = 'x' and age > 20; | IndexScan (table=t, index=ca, key=('x'), range: age > 20) |
explain select * from t where age = 30; | Filter (age = 30) over SeqScan (table=t) |
explain select city, id from t where city = 'x'; | Project (cols=[city, id]) over IndexOnlyScan (table=t, index=ca, key=('x')) |
| a covered SELECT | [EXEC] rows_examined=<n> index_lookups=0 |
| an index column the table lacks; one listed twice | [ERROR:00402]; [ERROR:00408] <message> |
A one-column index prints as before: key='x', range: name > 'b'.
$droid --db people.dbWelcome to droid!>create table t (id int, city varchar(10), age int, name varchar(20));Create table statement executed successfully>create index ca on t (city, age);Create index statement executed successfully>insert into t (id, city, age, name) values (1, 'x', 30, 'ada');Insert statement executed successfully (1 rows)>insert into t (id, city, age, name) values (2, 'x', 25, 'bob');Insert statement executed successfully (1 rows)>insert into t (id, city, age, name) values (3, 'y', 30, 'cy');Insert statement executed successfully (1 rows)>select id, age from t where city = 'x';id | age2 | 251 | 30(2 rows)[EXEC] rows_examined=2 index_lookups=0>select name from t where city = 'x' and age = 30;nameada(1 rows)[EXEC] rows_examined=1 index_lookups=1>.exitexiting.. good bye!
= on the second used as one seek. After a range, the second column is no longer in order; it belongs in the Filter.key=(...) follows the index's column order, whatever order the WHERE was written in.select id, city is covered by an index on city.The page above is the same for every language. These are the only language-specific hints.
std::vector<Value> per entry with a lexicographic compare reads clearly; encode it once for the page.Vec<Value> with Ord implemented per value compares lexicographically for free.