Join Algorithms: Hash Join

Imagine joining a 100-million-row `Orders` table with a 5-million-row `Customers` table on `customer_id`. A Join Algorithms: Nested-Loop approach compares every pair of rows. At best, it uses an index to skip most comparisons. But if no useful index exists, the cost is prohibitive. Hash join solves this by exploiting a simple truth about equality: two tuples can only join if their key values hash to the same bucket. This lets a database convert a quadratic comparison problem into a linear-time operation. This works provided one of the inputs fits comfortably in memory. Hash join is the workhorse of modern relational engines for large equi-joins. Whenever a query optimizer sees `WHERE a.key = b.key` over tables too large for a nested-loop scan, and lacking a matching index, it reaches for hash join. Its efficiency comes from a clean division of labor: build a hash table on the smaller relation, then stream the larger relation through it, probing for matches. When memory is insufficient, the algorithm recursively partitions both inputs so that each partition pair fits in memory. This is the "partitioning-based" or "Grace" hash join, and it is the version most production systems implement. The big picture is this: hash join trades the comparison-heavy nature of nested-loop join for a hashing-heavy one. It achieves expected linear-time performance in the sizes of the two relations. This comes at the cost of needing workspace memory and losing any output ordering. It is one of the clearest illustrations in database systems of how a well-chosen data structure — the hash table — turns an expensive relational operator into a tractable one. This article builds the algorithm from first principles: the basic build/probe idea, what happens when data does not fit in memory (recursive partitioning), the precise cost model, a full worked trace, and the classic bugs that trip up implementations — skew, wrong hash function reuse across partition levels, and hash-table overflow.

Key takeaways
  • Hash join computes equi-joins by hashing one relation (the build input) into an in-memory hash table, then streaming the other (the probe input) through it.
  • When the build relation does not fit in memory, Grace hash join recursively partitions both relations into matching buckets using a hash function on the join key.
  • Expected running time is O(M + N) for relations of size M and N (in pages/tuples), versus O(M·N) for naive nested-loop join.
  • Correctness relies on the partitioning invariant: matching tuples always land in the same partition, because they hash identically.
  • Recursive partitioning repeats whenever a partition still exceeds available memory, using a different hash function at each level.
  • Weaknesses: no sorted output, vulnerable to data skew, and sensitive to poor memory-size estimation — all issues The Query Optimizer must reason about when choosing join algorithms.
The core insight

Hash join replaces pairwise comparison with a hash table lookup, turning an equi-join into an expected-linear-time operation by ensuring matching tuples always hash to the same bucket.

The idea

A hash join computes the result of an equi-join `R ⋈_{R.a = S.b} S` by exploiting a hash function `h` applied to the join attribute. The core observation is that if `r.a = s.b`, then `h(r.a) = h(s.b)` for any deterministic hash function `h`. This means all tuples that could match are guaranteed to land in the same hash bucket, while tuples in different buckets are guaranteed not to match. Comparison is therefore only ever needed within a bucket, not across the whole cross product. The simplest version, classic (in-memory) hash join, designates the smaller relation as the build input and the larger as the probe input. It scans the build relation once, inserting every tuple into an in-memory hash table keyed by the join attribute (the build phase). It then scans the probe relation once, hashing each tuple's join key and looking it up in the table, emitting a joined output tuple for every match found (the probe phase). If the build relation's hash table fits in memory, this costs one sequential scan of each relation — no repeated re-scanning as in nested-loop join. When the build relation is too large to fit in memory, the algorithm generalizes to Grace hash join (partitioning-based hash join, named after the GRACE database machine project). Both relations are first partitioned on disk using a hash function `h1` on the join attribute into `k` partitions each. Because `h1` is applied identically to both relations, tuples from `R` and `S` that could ever match are guaranteed to fall into partitions with the same index `i`: `R_i` can only join with `S_i`, never with `S_j` for `j ≠ i`. This is the partitioning invariant, and it is what makes the divide-and-conquer strategy correct: after partitioning, the original join reduces to `k` independent, smaller joins, each solvable by the in-memory algorithm — and if any partition pair is still too large, the process recurses with a fresh hash function `h2`, `h3`, and so on. This recursive step is called recursive partitioning.

How it works

The algorithm has two structurally distinct regimes, but both reduce to the same fundamental build/probe primitive once partitions are small enough.

Regime 1 — In-memory hash join (used directly, or on each partition pair after partitioning): 1. Choose the smaller relation as build input `B`, the other as probe input `P`. 2. Scan `B`; for each tuple, compute `h(key)` and insert into an in-memory hash table. 3. Scan `P`; for each tuple, compute `h(key)`, look up the bucket, and for every build tuple in that bucket with an equal key, output the joined pair.

Regime 2 — Grace (partitioning) hash join (used when `B` does not fit in memory): 1. Partition phase. Using hash function `h1` with `k` buckets, scan `R` and write each tuple to partition file `R_1, …, R_k`. Do the same for `S`, using the same `h1`, producing `S_1, …, S_k`. 2. Recursive check. For each `i`, if `R_i` (the smaller of the pair) fits in available memory, proceed to build/probe on `(R_i, S_i)` directly. Otherwise, recursively partition `R_i` and `S_i` using a new hash function `h2`, producing sub-partitions, and repeat. 3. Build/probe phase. For each final partition pair `(R_i, S_i)` that fits in memory, run the in-memory hash join: build a hash table on the smaller side, probe with the other, emit matches. The number of partitions `k` is chosen so that each resulting partition of the build relation is expected to fit in `M` memory pages — roughly `k ≈ ⌈B / M⌉` where `B` is the build relation's size in pages, though real systems add a fudge factor for hash-table overhead and uneven bucket sizes. ``` function HASH_JOIN(R, S, M): // M = number of memory buffer pages available if size(R) > size(S): swap(R, S) // R becomes the (smaller) build side if fits_in_memory(R, M): return BUILD_PROBE(R, S) else: return GRACE_HASH_JOIN(R, S, M) function GRACE_HASH_JOIN(R, S, M): k = choose_partition_count(size(R), M) // e.g. k = ceil(size(R) / M) h1 = new_hash_function() // --- Partition phase --- R_parts = PARTITION(R, h1, k) // R_parts[i] is a file on disk S_parts = PARTITION(S, h1, k) result = [] for i in 1..k: Ri, Si = R_parts[i], S_parts[i] if fits_in_memory(min(Ri, Si), M): result += BUILD_PROBE(Ri, Si) else: // recursive partitioning: partition sizes still exceed memory result += GRACE_HASH_JOIN(Ri, Si, M) // uses a fresh hash function internally return result function PARTITION(T, h, k): parts = [empty file for i in 1..k] for tuple t in T: i = h(t.joinKey) mod k append t to parts[i] return parts function BUILD_PROBE(B, P): // B = build (smaller), P = probe (larger), both in memory table = empty hash table for tuple b in B: table.insert(h2(b.joinKey), b) // in-memory hash function, distinct from partition hash output = [] for tuple p in P: bucket = table.lookup(h2(p.joinKey)) for b in bucket: if b.joinKey == p.joinKey: output.append(combine(b, p)) return output ``` A crucial implementation detail: the hash function used for partitioning (`h1`, `h2`, …) must be different from the hash function used for the in-memory hash table in `BUILD_PROBE`. If they were the same, every tuple within a partition would collide into the same bucket (since they all already share the same value under `h1`), destroying the point of hashing inside `BUILD_PROBE`. Real systems draw these from independent hash function families or use different seeds.

Figure
Grace hash join: partition phase then build/probe on matching buckets
R (build side) disk-resident, too large for memory S (probe side) disk-resident, larger table apply h1(key) mod k to every tuple in R and in S R_1 R_2 R_k S_1 S_2 S_k match: same i ⋯ ⋯ build hash table on R_i, probe with S_i

Complexity & correctness

Correctness argument. The key invariant is the partitioning invariant: for any hash function `h` and any two tuples `r ∈ R`, `s ∈ S` with `r.a = s.b`, it must be that `h(r.a) = h(s.b)`, since `h` is a deterministic function applied to equal inputs. Consequently, if `r` is written to partition `R_i` and `s` is written to partition `S_j` using the same `h1`, then `r` and `s` can only match if `i = j`. This means no true join result is ever lost by partitioning (no false negatives): every genuinely matching pair ends up co-located in some `(R_i, S_i)`. Conversely, the algorithm never fabricates a match across different partition indices, because `BUILD_PROBE` only compares tuples that were routed to the same file and additionally re-checks key equality explicitly (guarding against hash collisions within a bucket, which would otherwise cause false positives). The explicit equality check inside `BUILD_PROBE`'s inner loop is what makes hashing collision-safe: a shared bucket is only a candidate match, verified by direct comparison. Recursion terminates because each level of partitioning splits data into `k` roughly equal-sized pieces (assuming a well-distributed hash function and no extreme skew), so partition sizes shrink geometrically; after `⌈log_k(B/M)⌉` levels, every partition fits in memory `M`. In the worst case of severe key skew, one partition could contain nearly the entire relation and re-partitioning would not reduce its size — a real limitation discussed below.

Time complexity. Let `M` and `N` denote the sizes (in disk pages) of the build and probe relations, with `M ≤ N`, and let `B` be the number of memory buffer pages available.

  • In-memory hash join (no partitioning needed, `M ≤ B`): each relation is read exactly once from disk, giving I/O cost `O(M + N)`. CPU cost is `O(M + N)` expected, since hash-table insert and lookup are `O(1)` expected per tuple.
  • One-pass Grace partitioning: partitioning requires reading and rewriting both relations once — cost `O(M + N)` reads plus `O(M + N)` writes — after which each partition pair is joined in memory at cost proportional to its size, summing to `O(M + N)` again. Total: `O(M + N)` I/O for one partitioning pass, a constant factor (roughly 3×: one read+write per relation for partitioning, one more read for build/probe) worse than the in-memory case but still linear.
  • Recursive partitioning at depth d: each level re-reads and re-writes the data, so total I/O is `O(d · (M + N))` where `d = ⌈log_{k}(M / B)⌉` — logarithmic in the ratio of data size to memory size, not in `N` itself. In practice `d` is 0, 1, or rarely 2 for realistic memory sizes, since `k` (the fan-out) is typically in the hundreds.

This is the central efficiency claim: hash join is O(M + N) expected time (ignoring the small logarithmic partitioning-depth factor), dramatically better than nested-loop join's O(M · N) (block nested loop reduces this by a constant factor but remains quadratic in the number of blocks). The saving comes precisely from avoiding pairwise comparison — a `O(1)`-expected hash lookup replaces an `O(N)` inner scan.

Space complexity. The in-memory build phase requires `O(M)` memory for the hash table (where `M` here means the size of the smaller relation's partition, not the whole relation, once partitioned). Partitioning itself requires `O(k)` output buffers, one page each, so total buffer requirement is `B ≥ k + 2` (one input buffer, one output buffer per partition, plus a small constant); this constrains how large `k` can be chosen and is exactly why recursive partitioning is sometimes unavoidable — you cannot always jump straight to arbitrarily many partitions in one pass.

Honest tradeoffs. Hash join sacrifices output ordering entirely — unlike a merge join, its output is in whatever order probe-side matches happen to be found, so a query needing sorted output pays an extra sort afterward. It is also vulnerable to skew: if many tuples share the same join-key value (a "hash join hotspot"), they all land in one partition regardless of `k`, and that partition may never shrink under further hashing, forcing a fallback to nested-loop join or a specialized skew-handling strategy for that value. Finally, hash join's expected-time guarantee depends on good hash function quality and accurate estimates of relation size; if the optimizer misestimates and the chosen build side turns out too large for memory, performance degrades to disk-thrashing rather than smooth degradation.

Worked example

Suppose we join `Employees(emp_id, dept_id, name)` with `Departments(dept_id, dept_name)` on `dept_id`. Say `Employees` has 9 tuples and `Departments` has 4, and available memory buffer holds only 2 tuples worth of hash table at a time (artificially small, to force partitioning and make the trace instructive). We pick `Departments` as the build side (smaller), and use `k = 2` partitions with `h1(x) = x mod 2`.

Departments (dept_id, dept_name): (1,Sales), (2,Eng), (3,HR), (4,Ops)

Employees (emp_id, dept_id): (101,1), (102,2), (103,1), (104,3), (105,2), (106,4), (107,3), (108,1), (109,4) Partition phase using `h1(dept_id) = dept_id mod 2`:

PartitionDepartments tuplesEmployees tuples
Partition 0 (`dept_id` even)(2,Eng), (4,Ops)(102,2), (105,2), (106,4), (109,4)
Partition 1 (`dept_id` odd)(1,Sales), (3,HR)(101,1), (103,1), (104,3), (107,3), (108,1)

Each partition's build side (2 Departments tuples) now fits in the 2-tuple memory budget, so no further recursion is needed. Run `BUILD_PROBE` on each partition pair:

Partition 0 build/probe. Build hash table on `{(2,Eng), (4,Ops)}`. Probe with each Employees tuple:

Probe tupleLookup keyMatch foundOutput
(102,2)2(2,Eng)102, Eng
(105,2)2(2,Eng)105, Eng
(106,4)4(4,Ops)106, Ops
(109,4)4(4,Ops)109, Ops

Partition 1 build/probe. Build hash table on `{(1,Sales), (3,HR)}`. Probe with each Employees tuple:

Probe tupleLookup keyMatch foundOutput
(101,1)1(1,Sales)101, Sales
(103,1)1(1,Sales)103, Sales
(104,3)3(3,HR)104, HR
(107,3)3(3,HR)107, HR
(108,1)1(1,Sales)108, Sales

The final result is the union of both partitions' outputs — all 9 Employees tuples matched, exactly as a full join would require, and no employee from partition 0 was ever compared against a department in partition 1: the partitioning invariant made those comparisons provably unnecessary rather than merely skipped by luck. Total work: 4 Departments tuples and 9 Employees tuples were each read once during partitioning, then once again during build/probe within their partition — versus a naive nested-loop join which would perform 4 × 9 = 36 comparisons. Here only 4 comparisons total occurred inside `BUILD_PROBE` (2 hash lookups per partition, each an `O(1)`-expected operation), illustrating concretely how hashing collapses the comparison count.

Common pitfalls & traps

PitfallWhy it happensConsequence
Reusing the same hash function for partitioning and the in-memory tableImplementer doesn't realize tuples in a partition already share the same `h1` valueAll tuples collide into one bucket inside `BUILD_PROBE`, degrading it to linear scan per tuple
Choosing the larger relation as build sideOptimizer misestimates cardinalities, or implementer forgets to check sizesHash table doesn't fit in memory, forcing unnecessary spills or a crash
Ignoring data skewAssuming hash values distribute uniformlyOne partition remains oversized no matter how many times it's re-hashed, since all skewed tuples share the same key and thus the same hash
Forgetting the equality re-check after a hash matchAssuming equal hash implies equal keyHash collisions silently produce false-positive join results
Under-provisioning output buffers during partitioning (`k` too large for available `B`)Not respecting the constraint `B ≥ k + 2`Partition writes become inefficient (tiny writes per buffer) or the partition step fails outright
Terminating recursion too earlyOff-by-one in checking "does partition fit in memory"Build phase overflows memory anyway, or thrashes to disk mid-build

Pioneers & history

Hash join's partitioning strategy traces its lineage to the GRACE database machine project in Japan, which pioneered hardware-assisted relational hash-partitioning in the early 1980s, and to parallel database research at the University of Wisconsin that formalized and popularized the "hybrid hash join" used in commercial engines.

Origins · 1 minMasaru Kitsuregawa and how this was discovered
MK
Masaru Kitsuregawa
b. 1952 · Japanese computer scientist, architect of the GRACE hash-join and relational database machines

Kitsuregawa led the GRACE (GeneRAlized Cluster Engine) project, one of the first systems to demonstrate that hashing-based relational operators could be executed efficiently on custom parallel hardware, directly inspiring the partitioning hash join used in essentially every modern relational database engine.

History
1983
— GRACE database machine project demonstrates hash-based join partitioning on dedicated hardware.
1984
— DeWitt and colleagues at Wisconsin formalize hybrid hash join, blending in-memory and partitioned strategies.
1986
— Hash join analysis papers establish the linear-time cost model versus nested-loop and sort-merge alternatives.
1990
s — Commercial engines (Oracle, DB2, SQL Server, PostgreSQL) adopt hybrid and Grace hash join as default equi-join strategies.
2000
s–present — Vectorized and radix-partitioned hash joins become standard in columnar and in-memory analytic engines.

Where this matters

Every major relational database — PostgreSQL, MySQL, Oracle, SQL Server, and cloud analytic engines like Snowflake, BigQuery, and Spark SQL — implements hash join as a core physical operator, typically chosen by The Query Optimizer whenever an equi-join over large, unindexed relations is detected. Distributed query engines extend the idea to shuffle hash join, partitioning data across machines by hash of the join key so that matching rows land on the same node, mirroring the single-machine partitioning phase at cluster scale. In-memory analytic systems use radix hash join, a variant that partitions using bits of the hash value directly for cache efficiency, since modern CPU cache hierarchies make random-access hash-table probing expensive without careful data locality. Understanding hash join is also foundational for reasoning about join-order and algorithm-selection decisions made by the optimizer, since the choice between hash join, sort-merge join, and Join Algorithms: Nested-Loop fundamentally shapes query execution plans and, ultimately, Query Execution Plan cost estimates.

Ask the tutor: How does the query optimizer decide, at plan time, whether to use hash join, sort-merge join, or nested-loop join for a given equi-join?

Why not just use nested-loop join for every equi-join?

Nested-loop join compares every tuple of one relation against every tuple of the other, costing O(M·N) in the worst case, or O(M·N/B) with block-based buffering. For large relations without a usable index, this is far too slow — joining a million-row table against another million-row table could mean a trillion comparisons. Hash join instead uses the join key's hash value to instantly narrow candidate matches down to a single bucket, achieving expected O(M+N) time. Nested-loop join remains useful when relations are tiny, when a suitable index already exists (index nested-loop join), or for non-equi-join predicates like range conditions where hashing provides no benefit, since hashing fundamentally only helps with equality comparisons.

What determines whether hash join needs to recursively partition at all?

The deciding factor is whether the smaller (build) relation fits within the memory buffer pages available to the query. If it fits, the database runs the simple in-memory build/probe algorithm directly with a single scan of each relation. If it doesn't fit, the engine must partition both relations on disk so that each partition of the build side is small enough. If even a single partition remains too large after one partitioning pass — which happens when the build relation is many times larger than memory — the algorithm recurses, partitioning that oversized partition again with a new hash function, until every partition is small enough.

How does the query optimizer decide between hash join and sort-merge join?

The optimizer estimates the cost of each candidate algorithm using cardinality and memory estimates, then picks the cheapest plan. Hash join is typically favored for large, unsorted equi-joins with adequate memory, since it avoids the O(N log N) sorting cost that sort-merge join requires. Sort-merge join becomes attractive if one or both inputs are already sorted (say, from an index scan) or if the query requires sorted output afterward anyway, letting the sort cost be shared rather than paid separately. This tradeoff analysis is a central responsibility of The Query Optimizer, which must also account for available memory and estimated skew.

What happens if the optimizer picks the wrong relation as the build side?

If the larger relation is mistakenly chosen as the build side, its hash table may not fit comfortably in memory, forcing either an unplanned spill to disk mid-build or degraded performance from excessive hash-table resizing and cache misses. Some systems detect this dynamically and switch build/probe roles on the fly, a technique sometimes called "role reversal," but many simpler implementations commit to the choice made at plan time based on the optimizer's cardinality estimates. This is why accurate table and column statistics matter enormously for join performance, since a bad size estimate can silently sabotage an otherwise correct algorithm.

Does hash join preserve any ordering from the input relations?

No — hash join's output order depends entirely on the order in which probe-side tuples happen to arrive and find matches in the hash table, bearing no relationship to any sort order of either input relation. This is a genuine disadvantage compared to sort-merge join, which naturally produces output sorted by the join key as a side effect of its merge step. Queries with an `ORDER BY` clause on the join key following a hash join must pay for an explicit sort afterward, whereas the same query using sort-merge join might get that ordering for free.

How does skew break the recursive partitioning strategy, and what do real systems do about it?

If a join key has a small number of very frequent values, every tuple sharing that value hashes to the same bucket under every possible hash function, since the hash function is deterministic and only depends on the key value. No amount of further recursive partitioning can split those tuples apart, so that partition never shrinks to fit in memory no matter how many levels deep the recursion goes. Real database engines detect heavily skewed values ahead of time using statistics or histograms, and handle them separately — for instance, replicating the skewed build-side rows across all probe partitions or falling back to a nested-loop strategy just for those specific key values.

Is hash join used the same way in distributed and parallel database systems?

The core idea generalizes directly: in a distributed shuffle hash join, each machine partitions its local data by hashing the join key, then sends each partition to the machine responsible for that hash bucket, ensuring matching rows always co-locate on the same node. This mirrors the single-machine partitioning phase but adds network shuffle cost, which often dominates total execution time in distributed engines like Spark SQL or distributed data warehouses. In-memory analytic engines further refine the idea with radix partitioning, which partitions using specific bits of the hash value to maximize CPU cache locality during the build and probe phases, since random memory access patterns are especially costly on modern hardware.

Before this

Where this leads