Where the index comes from: nested loop, hash and sort-merge
Is the join's index built now, built earlier or replaced by order, and what does each choice cost in memory and I/O?
A join is a decision about where the index comes from
Relational operators described what a join demands. This article answers one question: given "find the rows of S that match this row of R", how do you avoid scanning S once for every row of R?
There are exactly three answers, and they cover all the possibilities:
- Build a lookup structure now, for this query, and throw it away. This is the hash join.
- Use a lookup structure someone already built and paid for. This is the indexed nested loop.
- Impose a common order on both sides, so that matching becomes a linear walk. This is the sort-merge join.
The naive nested loop is the fourth entry and the null answer: do not avoid the scan; pay |R|·|S|.
Distributed execution adds a second question: how do candidate matches end up on the same machine? The answers are again few:
- replicate the small side everywhere (broadcast);
- repartition both sides by key (shuffle);
- use a partitioning that already exists (co-partitioned);
- or move information about the data instead of the data (sideways information passing). This is a different category, and it is the most interesting one.
A structural observation to keep: the distributed algorithms are the local algorithms with the memory bound replaced by a network bound. An in-memory hash join is to a Grace hash join (defined below) as a broadcast join is to a shuffle join. Same recursion, different scarce resource.
The core tension
The hash join is the default in every analytic engine. Its linear cost rests on exactly two assumptions: the predicate is equality, and the keys are roughly uniform.
Look at the remaining items in order. Range joins, interval joins and as-of joins exist because equality failed. Skew handling exists because uniformity failed. The catalogue is not a list of techniques. It lists the two ways the default breaks.
The lattice of join algorithms
| Algorithm | Cost | Memory | Predicate | Provides order? | Reads all of S? |
|---|---|---|---|---|---|
| Naive nested loop | |R|·|S| | O(1) | any | probe side's | yes, repeatedly |
| Block nested loop | |R| + ⌈|R|/M⌉·|S| | M | any | probe side's | ⌈|R|/M⌉ times |
| Indexed nested loop | |R|·c_probe | O(1) | index-supported | index order | no |
| Hash join (in memory) | |R| + |S| | O(build) | equality | probe side's | yes |
| Grace / hybrid hash | plus 2(|R|+|S|) I/O | M | equality | nothing | yes |
| Sort-merge | sorts + O(|R|+|S|) | O(largest duplicate run) | equality, band, inequality | the join key | yes |
| Broadcast | |S_small|·P network | |S_small| × P | as the local algorithm | probe side's | yes |
| Shuffle | (|R|+|S|) network | build / P | as the local algorithm | nothing | yes |
| Co-partitioned | 0 network | build / P | as the local algorithm | bucket order | yes |
(Here M is memory, c_probe is the cost of one index lookup, and the build side is the input that goes into the hash table. The other input, which streams through it, is the probe side.)
Two columns carry more weight than they appear to. "Provides order" is what the enforcer idea predicts will matter: sort-merge is the only algorithm that hands a usable ordering to the next operator, which is how it earns back the log factor it paid. "Reads all of S" has exactly one "no". That single entry is why the indexed nested loop has not been replaced in forty years.
Nested loop: the only algorithm with no assumptions
Naive nested loop rescans S for every row of R.
Block nested loop buffers R in chunks of M pages and scans S once per chunk, so the I/O is |R| + ⌈|R|/M⌉·|S|. The chunk size is memory, and the fixed cost being shared is a full pass over S. It is the plainest case of fixed cost sets the batch size.
- pages of the outer and inner input
- pages of the outer held per chunk
Data table
| Memory M (pages) | Page reads |
|---|---|
| 10 | 1M |
| 10.7 | 941K |
| 11.4 | 881K |
| 12.2 | 821K |
| 13 | 771K |
| 13.9 | 721K |
| 14.9 | 681K |
| 15.9 | 631K |
| 17 | 591K |
| 18.1 | 561K |
| 19.4 | 521K |
| 20.7 | 491K |
| 22.1 | 461K |
| 23.7 | 431K |
| 25.3 | 401K |
| 27 | 381K |
| 28.9 | 351K |
| 30.8 | 331K |
| 32.9 | 311K |
| 35.2 | 291K |
| 37.6 | 271K |
| 40.2 | 251K |
| 42.9 | 241K |
| 45.9 | 221K |
| 49 | 211K |
| 52.4 | 201K |
| 56 | 181K |
| 59.8 | 171K |
| 63.9 | 161K |
| 68.3 | 151K |
| 72.9 | 141K |
| 77.9 | 131K |
| 83.3 | 131K |
| 89 | 121K |
| 95 | 111K |
| 102 | 101K |
| 109 | 101K |
| 116 | 91K |
| 124 | 91K |
| 132 | 81K |
| 141 | 81K |
| 151 | 71K |
| 161 | 71K |
| 173 | 61K |
| 184 | 61K |
| 197 | 61K |
| 210 | 51K |
| 225 | 51K |
| 240 | 51K |
| 257 | 41K |
| 274 | 41K |
| 293 | 41K |
| 313 | 41K |
| 335 | 31K |
| 357 | 31K |
| 382 | 31K |
| 408 | 31K |
| 436 | 31K |
| 466 | 31K |
| 498 | 31K |
| 532 | 21K |
| 568 | 21K |
| 607 | 21K |
| 649 | 21K |
| 693 | 21K |
| 741 | 21K |
| 791 | 21K |
| 845 | 21K |
| 903 | 21K |
| 965 | 21K |
| 1.03K | 11K |
| 1.1K | 11K |
| 1.18K | 11K |
| 1.26K | 11K |
| 1.34K | 11K |
| 1.44K | 11K |
| 1.53K | 11K |
| 1.64K | 11K |
| 1.75K | 11K |
| 1.87K | 11K |
| 2K | 11K |
Notice what happens when |R| ≤ M: the cost is |R| + |S|, the same as a one-pass hash join. Block nested loop is asymptotically optimal whenever one side fits in memory, and it makes no assumption about the predicate. That is its permanent niche. Any condition a function can evaluate (a geographic predicate, string similarity, a UDF, an inequality with three terms) falls back to it, and nothing better exists in the general case.
Indexed nested loop replaces the inner scan by a probe. The cost is |R| × c_probe. Compared with |R| + |S| it wins when |R| < |S| / c_probe. The whole question is c_probe, and c_probe is a random access. So the real coefficient is the ratio of random to sequential throughput on the storage:
- On rotating disks that ratio was about 100×. That is why analytic systems abandoned the indexed nested loop.
- On NVMe SSDs (fast flash drives) at high queue depth it is closer to 2–10×, which quietly widened the algorithm's range again.
- On object storage there is no ratio, just a per-request latency floor of tens of milliseconds and a per-request price. That pushes the range out again.
The break-even point of this algorithm has moved by three orders of magnitude across storage generations, in both directions. "Indexed nested loop is for OLTP (transaction processing) and hash join is for analytics" is true of one decade's hardware, not of the algorithms.
Its unique property: it is the only join whose cost is sublinear in |S|. Every other algorithm reads both inputs at least once, so |R| + |S| is their floor. The indexed nested loop never reads most of S. When selectivity is high enough, that is not a constant-factor advantage but a different complexity class, and no amount of vectorization closes it.
Hash join: a disposable index, and the one place recursion fails
Build a hash table on the smaller side, then stream the larger side through it. The cost is |R| + |S|, and the memory is proportional to the build side.
# Hash join in 6 lines (in memory)
table = {}
for s in S: # build phase: blocking
table.setdefault(s.key, []).append(s)
for r in R: # probe phase: streaming
for s in table.get(r.key, []):
emit(r, s)Three properties come from the structure, not from the implementation:
- The build is blocking and the probe is streaming. A hash join is half a pipeline breaker. Because the probe side streams through untouched, it keeps its ordering and its partitioning. So choosing the build side is not only a size decision: putting the already-partitioned side on the probe side keeps a property that would otherwise be re-enforced later.
- The load factor (how full the hash table is, ρ) is the queueing curve inside the operator. Probe cost rises like 1/(1−ρ) with open addressing (a hash table design that stores entries directly in the slot array). That is why engines size tables for ρ around 0.5, and why underestimating the build size makes cost grow faster than linearly.
- Null keys can be dropped at build time, because they never match (Relational operators).
When the build side does not fit: Grace hash
Grace hash join partitions both sides with a hash function h₁ into p partitions, writes (spills) them to disk, then joins each pair of partitions separately with a second hash function h₂. The extra I/O is 2(|R|+|S|): one write and one read of each side.
Two limits bound p:
- p must be at least |build| / M, so that one build partition fits in memory;
- p must be at most the fan-out F = M/B that the I/O block size allows (one buffer per open partition).
Together: recursion begins when |build| > M²/B. This is the same threshold as external merge sort (see Why a query has many plans). With M = 16 GB and B = 8 MB that is 32 TB of build side per instance.
Why the second hash function matters. If h₁ were reused inside a partition, every key in that partition would share the same h₁ value, so they would all collide in the inner hash table, which would degenerate into a list.
Hybrid hash: a ramp, not a cliff
Hybrid hash keeps partition zero resident in memory throughout, so M bytes are never written or read back. The transition from in-memory to spilled is a linear ramp, not a cliff. If the build side has size m > M, the spilled fraction is (m−M)/m. The ramp is measured against a fully spilled join; against the in-memory join, even a small spill is a cliff, because each spilled byte costs the memory-to-disk bandwidth ratio more.
- the build side
- memory for the build side
Data table
| Build side ÷ memory m/M | Spilled |
|---|---|
| 0.5 | 0% |
| 0.519 | 0% |
| 0.539 | 0% |
| 0.559 | 0% |
| 0.581 | 0% |
| 0.603 | 0% |
| 0.626 | 0% |
| 0.65 | 0% |
| 0.675 | 0% |
| 0.7 | 0% |
| 0.727 | 0% |
| 0.755 | 0% |
| 0.784 | 0% |
| 0.814 | 0% |
| 0.845 | 0% |
| 0.877 | 0% |
| 0.91 | 0% |
| 0.945 | 0% |
| 0.981 | 0% |
| 1.02 | 1.82% |
| 1.06 | 5.43% |
| 1.1 | 8.9% |
| 1.14 | 12.3% |
| 1.18 | 15.5% |
| 1.23 | 18.6% |
| 1.28 | 21.6% |
| 1.32 | 24.5% |
| 1.37 | 27.2% |
| 1.43 | 29.9% |
| 1.48 | 32.5% |
| 1.54 | 35% |
| 1.6 | 37.4% |
| 1.66 | 39.7% |
| 1.72 | 41.9% |
| 1.79 | 44% |
| 1.85 | 46.1% |
| 1.93 | 48.1% |
| 2 | 50% |
| 2.07 | 51.8% |
| 2.15 | 53.6% |
| 2.24 | 55.3% |
| 2.32 | 56.9% |
| 2.41 | 58.5% |
| 2.5 | 60% |
| 2.6 | 61.5% |
| 2.7 | 62.9% |
| 2.8 | 64.3% |
| 2.91 | 65.6% |
| 3.02 | 66.9% |
| 3.13 | 68.1% |
| 3.25 | 69.2% |
| 3.38 | 70.4% |
| 3.5 | 71.5% |
| 3.64 | 72.5% |
| 3.78 | 73.5% |
| 3.92 | 74.5% |
| 4.07 | 75.4% |
| 4.23 | 76.3% |
| 4.39 | 77.2% |
| 4.55 | 78% |
| 4.73 | 78.9% |
| 4.91 | 79.6% |
| 5.1 | 80.4% |
| 5.29 | 81.1% |
| 5.49 | 81.8% |
| 5.7 | 82.5% |
| 5.92 | 83.1% |
| 6.15 | 83.7% |
| 6.38 | 84.3% |
| 6.62 | 84.9% |
| 6.88 | 85.5% |
| 7.14 | 86% |
| 7.41 | 86.5% |
| 7.69 | 87% |
| 7.99 | 87.5% |
| 8.29 | 87.9% |
| 8.61 | 88.4% |
| 8.94 | 88.8% |
| 9.28 | 89.2% |
| 9.63 | 89.6% |
| 10 | 90% |
The dead end
If the rows of a single key exceed M, no value of p helps. Partitioning divides the key space, and one key is an atom of that space: h₁ maps one value to one partition by definition. Recursive partitioning, the escape that rescues every other out-of-core algorithm here, is unavailable. This is not a tuning failure. It is the whole reason skew handling is a separate topic.
Sort-merge: paying a log factor for two things hash cannot sell
Sort both inputs on the join key, then walk through them together with two pointers. Against the hash join's |R| + |S| this looks like a plain loss, and on a single machine in memory it often is. It buys two things.
1. Constant memory in the merge phase. The hash join's memory is proportional to the build side. The merge's memory is proportional only to the largest run of duplicate keys. When memory is the binding constraint (usually true in a multi-tenant engine), sort-merge cannot blow up. That is why it is the fallback when a memory request is refused.
2. An ordering on the join key in the output. A later aggregation, window function or second merge join on the same key uses it for free. That is where the log factor is earned back. And the reverse matters more: if either input is already sorted (a clustered index, a sorted bucketed table, LSM-ordered files (files kept sorted by a log-structured merge tree, explained in Sorting), the output of an earlier merge), half or all of the sort was pre-paid, and merge join beats hash join outright. The merge itself is linear with O(1) memory. The algorithm that requires the property the data already has is the cheap one.
Two liabilities:
- A many-to-many merge must buffer the run of equal keys on one side and re-walk it for each matching row on the other. If the run is large, it spills. So skew hurts merge join as well, through the duplicate run instead of the partition.
- The sort destroys any earlier ordering on anything except the join key.
A note on folklore. "Hash join always beats sort-merge" comes from single-machine, row-oriented systems with narrow SIMD (instructions that process several values at once). The research on in-memory joins has gone back and forth for a decade, and the answer changes with vector width, NUMA layout (how memory is attached to CPU sockets) and key width. At single-node level it is genuinely unsettled. In a distributed engine the question barely matters, because the shuffle dominates both and the local algorithm is noise.