Skip to content
About
Main content

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:

  1. Build a lookup structure now, for this query, and throw it away. This is the hash join.
  2. Use a lookup structure someone already built and paid for. This is the .
  3. Impose a common order on both sides, so that matching becomes a linear walk. This is the .

The naive nested loop is the fourth entry and the 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 ();
  • use a partitioning that already exists (co-partitioned);
  • or move information about the data instead of the data (). 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 (defined below) as a 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 exist because equality failed. 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

AlgorithmCostMemoryPredicate order?Reads all of S?
Naive nested loop|R|·|S|O(1)any'syes, repeatedly
|R| + ⌈|R|/M⌉·|S|Manyprobe side's⌈|R|/M⌉ times
Indexed nested loop|R|·c_probeO(1)index-supportedindex orderno
Hash join (in memory)|R| + |S|O(build)equalityprobe side'syes
Grace / hybrid hashplus 2(|R|+|S|) I/OMequalitynothingyes
Sort-mergesorts + O(|R|+|S|)O(largest duplicate run)equality, band, inequalitythe join keyyes
Broadcast|S_small|·P network|S_small| × Pas the local algorithmprobe side'syes
Shuffle(|R|+|S|) networkbuild / Pas the local algorithmnothingyes
Co-partitioned0 networkbuild / Pas the local algorithmbucket orderyes

(Here M is memory, c_probe is the cost of one index lookup, and the build side is the input that goes into the . 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 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
Page reads by memory (|R| = 1,000, |S| = 10,000 pages)
Memory M (pages)Page reads
101M
10.7941K
11.4881K
12.2821K
13771K
13.9721K
14.9681K
15.9631K
17591K
18.1561K
19.4521K
20.7491K
22.1461K
23.7431K
25.3401K
27381K
28.9351K
30.8331K
32.9311K
35.2291K
37.6271K
40.2251K
42.9241K
45.9221K
49211K
52.4201K
56181K
59.8171K
63.9161K
68.3151K
72.9141K
77.9131K
83.3131K
89121K
95111K
102101K
109101K
11691K
12491K
13281K
14181K
15171K
16171K
17361K
18461K
19761K
21051K
22551K
24051K
25741K
27441K
29341K
31341K
33531K
35731K
38231K
40831K
43631K
46631K
49831K
53221K
56821K
60721K
64921K
69321K
74121K
79121K
84521K
90321K
96521K
1.03K11K
1.1K11K
1.18K11K
1.26K11K
1.34K11K
1.44K11K
1.53K11K
1.64K11K
1.75K11K
1.87K11K
2K11K

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 , 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 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:

  1. The build is blocking and the probe is streaming. A hash join is half a . 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.
  2. 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.
  3. 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 both sides with a hash function h₁ into p partitions, writes () 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 . 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
Share of the build side spilled, by how far it exceeds memory
Build side ÷ memory m/MSpilled
0.50%
0.5190%
0.5390%
0.5590%
0.5810%
0.6030%
0.6260%
0.650%
0.6750%
0.70%
0.7270%
0.7550%
0.7840%
0.8140%
0.8450%
0.8770%
0.910%
0.9450%
0.9810%
1.021.82%
1.065.43%
1.18.9%
1.1412.3%
1.1815.5%
1.2318.6%
1.2821.6%
1.3224.5%
1.3727.2%
1.4329.9%
1.4832.5%
1.5435%
1.637.4%
1.6639.7%
1.7241.9%
1.7944%
1.8546.1%
1.9348.1%
250%
2.0751.8%
2.1553.6%
2.2455.3%
2.3256.9%
2.4158.5%
2.560%
2.661.5%
2.762.9%
2.864.3%
2.9165.6%
3.0266.9%
3.1368.1%
3.2569.2%
3.3870.4%
3.571.5%
3.6472.5%
3.7873.5%
3.9274.5%
4.0775.4%
4.2376.3%
4.3977.2%
4.5578%
4.7378.9%
4.9179.6%
5.180.4%
5.2981.1%
5.4981.8%
5.782.5%
5.9283.1%
6.1583.7%
6.3884.3%
6.6284.9%
6.8885.5%
7.1486%
7.4186.5%
7.6987%
7.9987.5%
8.2987.9%
8.6188.4%
8.9488.8%
9.2889.2%
9.6389.6%
1090%

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 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 , 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 , 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.