Hash Join
Read each side once: build a hash table on one, probe it with the other
With --join hash, join on equality by reading the right input once into a hash table keyed by the join columns and the left input once to probe it, with duplicate keys all matching, colliding keys told apart, NULL keys never matching, and LEFT JOIN padding.
- Equality joins can be answered by hashing: same key, same bucket
- Build once, probe once: each input is read a single time
- Duplicate keys and hash collisions are different things, and both must be right
- A NULL key is never inserted and never found
- The left side probes, so a LEFT JOIN can pad as it goes
Why this stage exists
The index nested loop is fast when there is an index and few outer rows. Take both away, with two big tables, no index and a report joining everything, and the nested loop is back to reading the inner table once per outer row: quadratic.
A hash join reads each table once. It is how analytical databases join, and with no index it is usually the fastest equality join there is.
Build, then probe
- Build. Read the right input to the end, putting each row in a hash table under the value of its join column: every order goes into the bucket of its
user_id. - Probe. Read the left input. For each user, hash its
id, look in that bucket, and pair the user with each order found there.
Only equality works: a hash says nothing about which keys are smaller, so on u.id < o.user_id cannot be hashed and stays a nested loop.
The right side always builds, and the left side always probes. That keeps LEFT JOIN simple: the left rows are the ones that stream past, so a left row that found nothing is padded on the spot. Choosing which table to put on the right, ideally the smaller one, is a question for the join-order stage.
Duplicates are not collisions
Two different things can share a bucket, and a correct hash join gets both right:
- Duplicate keys. Three rows of
aand two rows ofbwithk = 7must give all six pairs. The bucket holds a list of rows, not one. - Collisions. Two different keys can hash to the same bucket. A table of 1024 buckets indexed by
key mod 1024puts 1024, 2048 and 3072 together. Those rows are in the bucket but are not partners. Always compare the key values, never just the hash.
NULL is the third trap. NULL = NULL is unknown, so a build row with a NULL key is never inserted, and a probe row with a NULL key finds nothing.
Order and counters
Rows come out in probe order, and for each probe row its matches in the order the build side read them. The [EXEC] line gains two fields, how many rows each side handed up:
[EXEC] rows_examined=300 hash_build_rows=200 hash_probe_rows=100
The algorithm in words
Hash keys
Which conjuncts the table is keyed by.
- 1.Every conjunct of the condition that is l = r or r = l, l a column on the left and r a column on the right, is a hash key, in the order written.
- 2.The other conjuncts are the residual. With no hash key, the join is a nested loop.
Running a HashJoin
Build, then probe.
- 1.Build: read the right child to the end. Insert each row whose keys are all non-NULL into the table, under its key values, keeping the order rows arrived in.
- 2.Probe: read the left child row by row. A row with a NULL key pairs with nothing.
- 3.Otherwise hash its keys, and in that bucket take only the rows whose key values are equal to its own. Test the residual on each, and hand up the pairs for which it is true, in build order.
- 4.Under a LEFT JOIN, a left row that paired with nothing is handed up padded.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, section 70.
| Input | Output |
|---|---|
--join hash, users join orders (100 and 200 rows) | [EXEC] rows_examined=300 hash_build_rows=200 hash_probe_rows=100 |
explain of it | HashJoin (on: u.id = o.user_id) over the users scan, then the orders scan |
| a LEFT JOIN | HashJoin (left, on: u.id = o.user_id) |
on u.id < o.user_id with --join hash | NestedLoopJoin (on: u.id < o.user_id) |
What you should see when done
$droid --db hash.db --join hashWelcome to droid!>create table a (id int primary key, k int);Create table statement executed successfully>create table b (id int primary key, k int);Create table statement executed successfully>insert into a (id, k) values (1, 7);Insert statement executed successfully (1 rows)>insert into a (id, k) values (2, NULL);Insert statement executed successfully (1 rows)>insert into b (id, k) values (1, 7);Insert statement executed successfully (1 rows)>insert into b (id, k) values (2, 7);Insert statement executed successfully (1 rows)>select a.id, b.id from a join b on a.k = b.k;id | id1 | 11 | 2(2 rows)[EXEC] rows_examined=4 hash_build_rows=2 hash_probe_rows=2>.exitexiting.. good bye!
Common bugs in this stage
- Keeping one row per key. Duplicate keys must all match.
- Matching on the hash alone. Colliding keys are not equal.
- Inserting NULL keys, or matching a NULL probe key against them.
- Building on the left. The right child builds; the left one probes and is padded under LEFT JOIN.
- Losing build order inside a bucket. Matches come out in the order the build side read them.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: an array of buckets, each the head of a linked list of rows appended at the tail, is enough; FNV-1a over the key's bytes is an easy hash.
- C++:
std::unordered_multimapkeeps duplicates, but not their order across rehashes; a map from key tostd::vector<Row>does. - Rust:
HashMap<Vec<Value>, Vec<Row>>withValuederivingHashandEqgives duplicates in order and compares keys for you. - Zig:
std.HashMapwith a context that hashes and compares your key values, mapping to anArrayListof rows.
Checklist
One line per thing the tests check. The tags name the test cases.
- Each side is read once: the right side builds, the left side probesbuild-and-probe-counts
- Every build row with the key matches: 3 rows by 2 rows on one key give 6duplicate-build-keys
- Keys that share a hash bucket are still told apart: each row joins its own match onlycollisions-resolved
- NULL keys on both sides never matchnull-keys
- A join with no equality between the sides cannot hash: it is a nested loopnon-equi-falls-back
- Conditions beside the equality are tested on each matched pairresidual-predicate
- A LEFT JOIN probes with the left side, so an unmatched left row is paddedleft-join-supported
- Without ORDER BY: probe order, and each probe row's matches in build orderhash-order
- Keys of any type hash, and compare as valuesstring-keys
- explain: HashJoin with the probe side first and the build side second; LEFT shows left; a < join is not hashedhash-plans