When the Build Side Does Not Fit
Partition both inputs by hash to disk, then join one partition at a time
Bound a hash join's memory with --join-memory: when the build side has more rows than the limit, split both inputs into ceil(b / limit) partitions by a hash of their keys in the temporary file, join partition by partition, and report partitions=P.
- Rows with equal keys hash to the same partition, so partitions can be joined independently
- Partitioning both sides with the same hash and the same count is what makes it correct
- Skew: no hash can split one key's rows
- A spilled join gives the same rows in no promised order
- The join-side twin of the external sort, using the same temporary file
Why this stage exists
A hash join holds its whole build side in memory. Ten thousand orders fit; a billion do not. The external-sort stage solved the same problem for ORDER BY by writing sorted runs to disk. This stage solves it for hash joins, with the same temporary file and the same kind of limit: --join-memory <rows>, 10000 by default.
Partitions
The idea that makes it work: rows with equal keys hash to the same value. So if both inputs are split by the same hash into the same number of partitions, every pair that can join lies inside one partition. Each partition can be joined on its own, and a partition is small enough to fit.
The number of partitions is fixed by the build side's size: P = ceil(b / limit). With 200 build rows and --join-memory 10, that is 20. A build side of exactly limit rows fits, and is one partition.
So the join reads the build side, writing it to the temporary file once it no longer fits; splits it into P partitions; reads the probe side into the same P partitions; then builds and probes one partition at a time. Each input is read once and written once more.
Skew
One key with more rows than the limit cannot be split by any hash: all its rows go to one partition. Real engines split again with a second hash, which does not help either, and then fall back to something slower. Here the rule is simple: the limit decides how many partitions there are, and an oversized partition is joined in memory all the same. The join never fails because of the limit.
What spilling changes
The same rows come out, but not in probe order: partition by partition. A spilled join promises no order, so a query that needs one must say ORDER BY. The database file and the log are not touched, and the temporary file is gone when the statement ends.
partitions=<P> follows hash_probe_rows on the [EXEC] line, 1 when nothing spilled:
[EXEC] rows_examined=300 hash_build_rows=200 hash_probe_rows=100 partitions=20
The algorithm in words
Grace hash join
Partition, then join each partition in memory.
- 1.Read the build side. While it fits in --join-memory rows, hold it. When one more row arrives, write all held rows, and every later build row, to the temporary file.
- 2.When the build side ends with b rows, set P = ceil(b / limit), and split the build rows into P partitions by a hash of their keys. Drop rows with a NULL key.
- 3.Read the probe side and write each row to the partition its keys hash to, with the same hash and the same P. A probe row with a NULL key pairs with nothing; under LEFT JOIN hand it up padded.
- 4.For each partition: build a hash table from its build rows and probe it with its probe rows, padding under LEFT JOIN.
- 5.Delete the temporary file when the statement ends.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, section 71.
| Input | Output |
|---|---|
--join hash --join-memory 10, users join orders | [EXEC] rows_examined=300 hash_build_rows=200 hash_probe_rows=100 partitions=20 |
the same with --join-memory 200 | ... partitions=1 |
--join-memory 0, --join-memory abc or --join-memory 1000001 | [ERROR:00109] ..., exit status 1 |
--join-memory with nothing after it | [ERROR:00102] ..., exit status 1 |
What you should see when done
$droid --db spill.db --join hash --join-memory 1Welcome 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, 1);Insert statement executed successfully (1 rows)>insert into b (id, k) values (1, 1);Insert statement executed successfully (1 rows)>insert into b (id, k) values (2, 2);Insert statement executed successfully (1 rows)>insert into b (id, k) values (3, 1);Insert statement executed successfully (1 rows)>select a.id, b.id from a join b on a.k = b.k order by b.id;id | id1 | 11 | 3(2 rows)[EXEC] rows_examined=4 hash_build_rows=3 hash_probe_rows=1 partitions=3 sort_runs=1>.exitexiting.. good bye!
Common bugs in this stage
- Different hashes or partition counts on the two sides. A pair split across partitions is lost.
- Promising probe order. Rows come partition by partition.
- Refusing a partition over the limit. Skewed keys must still join.
- Leaving the temporary file behind, or writing it into the database file.
- Counting
partitions=0for a join that fits: it is one partition.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: reuse the run writer of the external sort; a partition is a run that is not sorted.
- C++: one output buffer per partition, flushed to its own page chain in the temporary file.
- Rust: a
Vecof partition writers indexed byhash % P;BufWriterper partition is fine for P in the hundreds. - Zig: an array of P small buffers, each with the page number of its chain's tail.
Checklist
One line per thing the tests check. The tags name the test cases.
- A build side larger than --join-memory is joined in ceil(b / memory) partitionspartitions-when-needed
- A build side that fits is one partition, also when it has exactly --join-memory rowsone-partition-when-it-fits
- Spilled or not, the join gives the same rowssame-result-either-way
- One key with more rows than the memory limit is still joined, all of itskewed-key
- A LEFT JOIN that spills still pads every left row with no partnerleft-join-spills
- A spilling SELECT leaves no temporary file and does not change the databasetemp-file-gone
- A spilling join works with a pool of 16 framesspill-under-small-pool
- --join-memory takes 1 to 1000000join-memory-option