Joining Through an Index
Look each outer row's partners up instead of reading the whole inner table
With --join inl, replace a join's inner scan by a lookup through the inner table's key or an index on its join column, run once per outer row, and report it in explain, in the counters, and as loops=N in explain analyze.
- An index turns 'read every inner row' into 'find the matching inner rows'
- The lookup value comes from the outer row, so the inner scan's key changes with every outer row
- A composite index helps only when the join column is its first column
- A NULL outer value looks nothing up
- loops=N: a node on the inner side runs once per outer row
Why this stage exists
A nested loop over 100 users and 200 orders examines 100 + 100 × 200 = 20100 rows to find 200 pairs. Almost all of that work is reading orders that belong to someone else. If orders has an index on user_id, each user's orders can be found in a few page reads, instead.
That is an index nested loop join: the same loop, with the inner scan replaced by a lookup whose key comes from the outer row. When the outer side is small, nothing beats it. It is how most OLTP queries join, and the reason almost every foreign key column gets an index.
A scan whose key changes
Until now an IndexScan's key was a literal: key=5. Here it is a column of the other table:
For each user, the join reads the user's id, runs the right-hand scan as if it were user_id = <that id>, and pairs the user with what it finds. The rest of the condition, if any, is tested on each pair, as before.
Which column can be looked up:
- the right table's key: joining orders to users on
u.id = o.user_idfinds each user by key,IndexScan (table=users, alias=u, key=o.user_id); - the first column of an index on the right table. An index on
(user_id, total)works; one on(total, user_id)does not, for the same reason a WHERE onuser_idalone cannot use it: its entries are intotalorder.
When the index holds every column the statement reads of that entry, the lookup is an IndexOnlyScan, as for one table: it never visits the table, so it adds nothing to index_lookups.
Anything else, such as no index, < instead of =, or an expression, stays a nested loop, and explain shows it. This is the first time --join can name an algorithm that does not apply, and the rule is the same for every later one: fall back to the nested loop, visibly.
A NULL outer value looks nothing up: NULL = anything is unknown. Under a LEFT JOIN, an outer row whose lookup finds nothing is padded, as in the last stage.
Counting runs
rows_examined adds up what every lookup returned, and index_lookups the rows fetched through an index entry, as for a single-table index scan. A lookup by the table's own key goes through no index entry, so it adds to rows_examined only.
explain analyze gains one field. A node on the right side of a nested loop runs many times, and loops=<L> says how many: the lookup above ends with (actual rows=200 pages=<P> loops=100). Its rows and pages add up over all its runs. Every node on the right side of a nested loop prints loops, even loops=1 or loops=0; a node anywhere else prints none, so earlier output does not change.
The algorithm in words
Choosing the lookup
Can the inner side be found instead of read?
- 1.The join's right child must be one entry's scan.
- 2.Find the first conjunct of the condition, in the order written, that is r = l or l = r, with r a column of the right entry and l a column of an entry on the left.
- 3.If r is the right table's key, look up by key. Otherwise use the index created first among those whose first column is r. If there is none, keep the nested loop.
- 4.Send every other conjunct that would have gone to the right scan into a Filter above the lookup.
Running an IndexNestedLoopJoin
One lookup per outer row.
- 1.Take the next left row and the value of l in it. If it is NULL, nothing pairs.
- 2.Otherwise run the right scan for r = value, exactly as an IndexScan with that key would run.
- 3.Test the rest of the condition on each pair and hand up the ones for which it is true.
- 4.Under a LEFT JOIN, pad the left row when nothing paired.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, section 69.
| Input | Output |
|---|---|
--join inl, users join orders, index on orders (user_id) | [EXEC] rows_examined=300 index_lookups=200 |
explain of it | IndexNestedLoopJoin (on: u.id = o.user_id) over SeqScan (table=users, alias=u) and IndexScan (table=orders, alias=o, index=by_user, key=u.id) |
| orders left join users | IndexNestedLoopJoin (left, on: u.id = o.user_id) with IndexScan (table=users, alias=u, key=o.user_id) |
explain analyze of ten users' orders | ... key=u.id) (actual rows=20 pages=<P> loops=10) |
What you should see when done
$droid --db inl.db --join inlWelcome to droid!>create table users (id int primary key, name varchar(20));Create table statement executed successfully>create table orders (id int primary key, user_id int, total int);Create table statement executed successfully>insert into users (id, name) values (1, 'ada');Insert statement executed successfully (1 rows)>insert into users (id, name) values (2, 'bob');Insert statement executed successfully (1 rows)>insert into orders (id, user_id, total) values (10, 2, 5);Insert statement executed successfully (1 rows)>insert into orders (id, user_id, total) values (11, 1, 7);Insert statement executed successfully (1 rows)>create index by_user on orders (user_id);Create index statement executed successfully>select u.name, o.total from users u join orders o on u.id = o.user_id;name | totalada | 7bob | 5(2 rows)[EXEC] rows_examined=4 index_lookups=2>.exitexiting.. good bye!
Common bugs in this stage
- Building the lookup once. The key changes with every outer row; the scan must be re-run with the new value.
- Looking up a NULL. A NULL key would find the entries whose value is NULL, which sort first in the index. It must find nothing.
- Using an index whose first column is not the join column. Its entries are not in that order.
- Counting
index_lookupsfor a lookup by the table key. No index entry is read. - Printing
loopson the outer side. It runs once.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: the inner scan should be repositionable to a new key without being freed and rebuilt for each outer row; keep the key in the scan's state.
- C++: the same holds: one scan object per join, reset with each outer row's key, avoids an allocation per row.
- Rust: passing the key by reference into the inner scan's reset keeps the outer row borrowed only while the lookup starts.
- Zig: store the current key in the scan's state; the allocator then sees one scan per join, not one per row.
Checklist
One line per thing the tests check. The tags name the test cases.
- Through an index on orders(user_id), each user's orders are looked up, not scannedlookups-not-scans
- Joining orders to users looks each user up by the table keypk-as-inner
- With --join inl and no usable index, the join is a nested loop and gives the same rowsno-index-falls-back
- A NULL on the outer side looks nothing up and joins nothingnull-outer-key
- Without ORDER BY: left order, and each left row's partners in index orderinl-order
- A composite index is used for a lookup only when the join column is its first columncomposite-prefix-only
- A LEFT JOIN through the index still pads users with no ordersleft-join-supported
- explain: IndexNestedLoopJoin over the outer scan and an IndexScan keyed by the outer columninl-plans
- explain analyze: the looked-up side ran once per outer rowloops-reported