droid
Joining Through an Index
Joins and Relations · Stage 4 of 13

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.

Not run yet · 9 tests
Concepts in this stage
  • 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
Where this stage sits in the engine
Talking to the user
REPL
Sessions
Printer
Server
Wire protocol
Understanding SQL
LexerParserValidator
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
PagerDatabase file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

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:

--join inl, index by_user on orders (user_id)
IndexNestedLoopJoin (on: u.id = o.user_id)
↑ 100 rows, read once
SeqScan (table=users, alias=u)
↑ 2 rows per user, 100 runs
ran
IndexScan (table=orders, alias=o, index=by_user, key=u.id)
For each user, the orders scan runs once with key = that user's id: 100 + 200 = 300 rows examined, 200 lookups.

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_id finds 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 on user_id alone cannot use it: its entries are in total order.

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

1

Choosing the lookup

Can the inner side be found instead of read?

  1. 1.The join's right child must be one entry's scan.
  2. 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. 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. 4.Send every other conjunct that would have gone to the right scan into a Filter above the lookup.
2

Running an IndexNestedLoopJoin

One lookup per outer row.

  1. 1.Take the next left row and the value of l in it. If it is NULL, nothing pairs.
  2. 2.Otherwise run the right scan for r = value, exactly as an IndexScan with that key would run.
  3. 3.Test the rest of the condition on each pair and hand up the ones for which it is true.
  4. 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.

InputOutput
--join inl, users join orders, index on orders (user_id)[EXEC] rows_examined=300 index_lookups=200
explain of itIndexNestedLoopJoin (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 usersIndexNestedLoopJoin (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

shell
$droid --db inl.db --join inl
Welcome 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 | total
ada | 7
bob | 5
(2 rows)
[EXEC] rows_examined=4 index_lookups=2
>.exit
exiting.. 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_lookups for a lookup by the table key. No index entry is read.
  • Printing loops on 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 scanned
    lookups-not-scans
  • Joining orders to users looks each user up by the table key
    pk-as-inner
  • With --join inl and no usable index, the join is a nested loop and gives the same rows
    no-index-falls-back
  • A NULL on the outer side looks nothing up and joins nothing
    null-outer-key
  • Without ORDER BY: left order, and each left row's partners in index order
    inl-order
  • A composite index is used for a lookup only when the join column is its first column
    composite-prefix-only
  • A LEFT JOIN through the index still pads users with no orders
    left-join-supported
  • explain: IndexNestedLoopJoin over the outer scan and an IndexScan keyed by the outer column
    inl-plans
  • explain analyze: the looked-up side ran once per outer row
    loops-reported