Join Algorithms: Sort-Merge
A retailer's database must join a 500-million-row `orders` table with a 50-million-row `customers` table on `customer_id`. Neither table fits in memory. Nested-loop join, even the block-oriented variant, degrades badly here. Without a usable index, it must rescan the inner relation once per outer block. This incurs I/O proportional to the product of the relation sizes. Something smarter is needed. We need an algorithm that touches each page a bounded, small number of times, regardless of how large the relations are. It should turn the combinatorial matching problem into a simple linear scan. Sort-merge join answers this by imposing order first and matching second. If both relations are sorted on the join attribute, rows that agree on that attribute become neighbors. Finding all matches then degenerates to walking two sorted streams in lockstep. This is the same trick used in the merge step of merge sort. It also appears in `merge` operations across databases, version-control diffs, and streaming analytics. The cost of imposing that order, when a relation is too large for memory, is paid by external sorting. This is a disk-aware generalization of ordinary sorting that itself deserves careful study. This article builds the algorithm in two layers. First, external merge sort: how a relation far larger than memory is sorted using a bounded number of passes over the data. Second, the merge-join phase: how two sorted relations are combined into join output with a single coordinated scan. This includes the subtlety of duplicate join-key values, which breaks the naive "two pointers, always advance the smaller" idea. Understanding Join Algorithms: Nested-Loop first clarifies exactly what sort-merge trades away — random-access flexibility. In exchange, it gains asymptotically better I/O behavior on large, unindexed, equi-join workloads. By the end, you will be able to compute the number of I/Os sort-merge join performs on relations of a given size and memory budget. You will trace both the sort and merge phases on concrete data. You will recognize when a query optimizer will prefer sort-merge over alternatives. And you will see why this algorithm anchors the reasoning behind The Query Optimizer's cost-based join selection.
- Sort-merge join first sorts both relations on the join attribute, then merges them in one coordinated linear pass.
- Sorting relations larger than memory uses external merge sort: create sorted runs, then merge them in passes.
- With memory buffer pages, external sort produces initial runs and needs merge passes.
- Total sort-merge join I/O cost is block transfers — near-linear, not quadratic.
- Duplicate join-key values require special handling in the merge phase: re-scanning a matched group of the inner relation.
- If both relations are already sorted (or indexed) on the join attribute, the sort phases are skipped and the join becomes essentially linear.
Sort both relations on the join key, then a single synchronized linear scan finds every matching pair — turning an matching problem into an one.
The idea
A sort-merge join (also called merge join) evaluates an equi-join by first sorting both and on their join attributes and , and then scanning both sorted relations exactly once in a coordinated fashion, emitting output tuples wherever the join attributes agree. The technique depends on a simple ordering fact: if and are both sorted on the join attribute, all tuples of and with a given join value occupy contiguous blocks of sorted position, so a two-pointer merge (as in classic merge sort) can locate every matching group without random access or repeated re-scanning of one whole relation per row of the other. When a relation does not already fit in main memory, "sorting it" is not a single in-memory operation — it requires external sorting, specifically the external merge sort algorithm. External sort has two phases: a run-generation phase, which reads the relation in memory-sized chunks, sorts each chunk internally, and writes it back as a sorted run; and a merge phase, which repeatedly merges groups of runs into longer sorted runs until one fully sorted run remains. The number of runs mergeable at once is bounded by the number of available memory buffer pages, which is why the merge phase may require several passes for very large relations. The join proper — the merge step — walks pointers over the two now-sorted relations. At each point it compares the current join-key values; if they differ, the pointer on the smaller side advances; if they are equal, the algorithm must handle the possibility of duplicate keys: a whole group of tuples on each side sharing that key value must be cross-matched, which requires temporarily "pinning" and possibly rewinding one side. This duplicate-handling detail is the single subtlety that separates a correct sort-merge join from a naive two-pointer scan that silently drops matches.
How it works
Phase 1 — External sort of both relations. Each relation is sorted on its join attribute using external merge sort, described below as two sub-stages. Run generation. With buffer pages available in memory, read pages of the relation at a time, sort them internally (e.g., quicksort or heapsort, per chunk), and write the sorted chunk to disk as one run. A relation of blocks yields initial sorted runs, each of length blocks. Merge passes. Runs are merged in groups. With buffer pages, can serve as input buffers (one page per input run being merged) and 1 page serves as the output buffer, so up to runs are merged at a time into one longer run. This halves — more precisely, divides by — the number of runs each pass. Passes repeat until a single run remains.
Phase 2 — Merge join. Once and are each a single sorted stream (on disk or effectively so), scan both with a coordinated merge, matching equal join-key groups and emitting the Cartesian product of each matching group. ``` // PART A: External Merge Sort (produces relation R sorted on attribute A) function EXTERNAL-SORT(relation R, join-attribute A, memory-pages M): runs := [] // --- Run generation --- for each chunk C of up to M pages read from R: load C into memory sort C in-memory by A // O(M log M) comparisons write sorted C to disk as a new run runs.append(run) // --- Merge passes --- while length(runs) > 1: new_runs := [] for each group G of up to (M - 1) runs from runs: new_run := MERGE-RUNS(G, A) // (M-1)-way merge, 1 output buffer new_runs.append(new_run) runs := new_runs return runs[0] // the single, fully sorted run
function MERGE-RUNS(runs G, attribute A): open one input buffer per run in G, load first page of each output_run := empty while any run in G has unread tuples: t := tuple with smallest A-value among current buffer heads append t to output_run (flush output buffer to disk when full) advance the pointer of the run t came from if that run's current buffer page is exhausted: load its next page (if any remain) return output_run
// PART B: Merge Join (R and S each sorted on join attribute) function MERGE-JOIN(R_sorted, S_sorted, attribute A, attribute B): result := empty r := first tuple of R_sorted s := first tuple of S_sorted while r != end-of-R and s != end-of-S: if r.A < s.B: r := next tuple of R_sorted else if r.A > s.B: s := next tuple of S_sorted else: // r.A == s.B : matching group begins. // Mark the start of S's matching group so it can be rewound // for every tuple in R's matching group with the same key. s_group_start := s while r != end-of-R and r.A == s.B: s := s_group_start while s != end-of-S and r.A == s.B: result.append(r joined with s) s := next tuple of S_sorted r := next tuple of R_sorted // s now points just past S's matching group; loop continues normally return result ``` The nested rewind (`s := s_group_start`) is the crux of correct duplicate handling: it is exactly analogous to the two-pointer technique used for computing intersections of sorted lists, but generalized to groups rather than single elements, so no matching pair is missed when either side repeats the join key.
Complexity & correctness
External sort cost. Let be the number of blocks in a relation and the number of memory buffer pages available.
- Run generation reads and writes every block once: block transfers, producing initial runs.
- Each merge pass also reads and writes every block once: transfers per pass. A pass merges groups of up to runs at a time, so the number of runs shrinks by a factor of each pass. Starting from runs, the number of passes needed to reach run is
- Total transfers for sorting one relation:
Merge-join cost. Once both relations are sorted, the merge phase reads each relation's sorted representation exactly once more (one additional pass each, since the final merge pass of the sort can be pipelined directly into the join without writing the fully sorted result back to disk — an important practical optimization). In the worst case (no pipelining), this adds transfers. Total:
This is near-linear in the relation sizes, in sharp contrast to block nested-loop join's when no index or sorted order is available. For very large relative to , sort-merge is asymptotically far superior; the crossover point in practice depends on , since shrinks quickly as grows (larger memory buffers mean fewer, wider merge passes).
Special case — one merge pass suffices. If (equivalently ), the entire relation can be sorted with a single run-generation pass followed by a single merge pass: two passes total, cost . This is the common case for moderately sized relations and a large buffer pool, and it is why sort-merge is often described loosely as "" in practical settings even though the general bound is .
Correctness of run generation. Each chunk of blocks fits entirely in memory, so an in-memory comparison sort correctly totally-orders it; concatenating disjoint sorted chunks as separate runs preserves sortedness within each run (not across runs), which is exactly the precondition the merge phase requires.
Correctness of the -way merge. By induction on the number of tuples emitted: at each step the algorithm outputs the globally smallest not-yet-output tuple among all input runs, because each run is internally sorted, so the smallest remaining tuple overall must be at the current head of some run. Maintaining one buffer pointer per run and always emitting the minimum head is therefore a correct merge, and repeating this until all runs are exhausted produces one sorted run containing all input tuples exactly once.
Correctness of the merge-join with duplicates. The invariant maintained by `MERGE-JOIN` is: at the top of the outer loop, every tuple pair with and preceding the current position in sort order has already been emitted, and none has been emitted twice. The group-rewind step is what preserves this invariant when the join key repeats: without rewinding back to `s_group_start` for each new in the matching group, only the first -tuple in the group would be matched against all -tuples in the group, silently losing result rows. The nested loop guarantees every pair within the matching groups is visited exactly once, giving the full cross product of the two groups, which is precisely the correct join semantics for that key value.
Space. External sort needs memory (a constant number of buffer pages), independent of — the entire point of an external algorithm. The merge-join phase needs only a few buffer pages beyond the sorted inputs, plus enough space to buffer one matching group of the inner relation for the rewind, which costs extra buffer pages where is the number of blocks spanned by the largest duplicate group (usually small, but adversarial data with a hugely repeated key can force this buffer to spill to disk too).
Tradeoffs. Sort-merge pays sorting cost up front regardless of how selective the join actually is; a highly selective join might be faster with an index nested-loop join that avoids touching most of the outer relation. Sort-merge shines precisely when no useful index exists, both relations are large, and the join is not highly selective — or when the relations are already sorted (e.g., a clustering index on the join column), in which case sort-merge degenerates to a nearly free linear merge.
Worked example
Join `R(A)` with `S(B)` where `R` has 8 blocks and `S` has 5 blocks, and the buffer pool has pages.
Sorting R (8 blocks, M = 3):
| Pass | Action | # runs before | # runs after | I/O this pass |
|---|---|---|---|---|
| 0 (run gen) | read 3-block chunks, sort in memory, write | — | ||
| 1 (merge) | merge up to runs at a time | 3 | ||
| 2 (merge) | merge remaining 2 runs | 2 | 1 |
Total for sorting R: block transfers, matching .
Sorting S (5 blocks, M = 3):
| Pass | # runs before | # runs after | I/O this pass |
|---|---|---|---|
| 0 (run gen) | — | ||
| 1 (merge) | 2 | 1 |
Total for sorting S: transfers.
Merge-join phase: one additional linear pass over each sorted relation (assuming the sorted runs are materialized on disk rather than pipelined): transfers.
Grand total: block transfers. Compare to block nested-loop join with the same (2 buffer pages for the outer relation's blocks per pass, 1 reserved for the inner scan — using the smaller relation S as outer): roughly
At this tiny scale, nested-loop actually wins because is too small for sort-merge's per-relation-size logarithm to pay off — a reminder that asymptotic superiority ( vs. ) only dominates once relations are large relative to . Re-running the comparison at , , makes sort-merge's advantage overwhelming, since nested-loop's cost grows with the product of relation sizes while sort-merge's grows with their sum times a small logarithm.
Tracing the merge step itself on small sorted data, , :
| Step | r pointer | s pointer | Comparison | Action |
|---|---|---|---|---|
| 1 | 10 | 15 | advance r | |
| 2 | 20 | 15 | advance s | |
| 3 | 20 | 20 | equal — group match begins | pin `s_group_start` at first 20 |
| 4 | 20 (1st) | 20,20,20 | inner loop | emit (20,20)×3, advance s through group |
| 5 | 20 (2nd) | rewind to `s_group_start` | inner loop again | emit (20,20)×3 again, advance s |
| 6 | 30 | 50 | advance r | |
| 7 | 45 | 50 | advance r → end of R |
Result: joined tuples on key 20, correctly capturing the full cross product of R's two 20's and S's three 20's — exactly the count a naive single-pointer merge (without rewinding) would have missed, producing only 3 instead of 6.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| Forgetting the group rewind for duplicate keys | Treating merge-join like simple sorted-list intersection | Buffer the matching group on one side and replay it for each match on the other side |
| Assuming sort cost is always | True only when (one merge pass) | Use the general formula for large relative to |
| Ignoring buffer allocation ( inputs + 1 output) | Off-by-one in run-merge fan-in | Reserve exactly one page per input run plus one output page per merge step |
| Materializing sorted runs to disk when pipelining is possible | Treating sort and join as fully separate phases | Feed the last merge pass of the sort directly into the join scan, saving one full read/write pass |
| Pitfall | Why it happens | Fix |
|---|---|---|
| Choosing sort-merge for tiny, highly selective joins | Ignoring that sorting cost is paid regardless of selectivity | Compare against index nested-loop; let the optimizer cost both |
| Skipping the sort when "the data looks sorted" | Assuming physical file order implies logical join-key order | Verify sortedness explicitly, or check for a clustering index guarantee |
Pioneers & history
External sorting and merge-based algorithms predate relational databases entirely — they originate in the era of magnetic tape processing, when "memory" meant a handful of kilobytes and data lived on sequential tape reels that made random access essentially impossible. The technique of producing sorted runs and merging them in passes was the standard way to sort files far larger than any machine's core memory, and it was formalized and popularized in the classic literature on sorting and searching.
Origins · 1 minDonald E. Knuth and how this was discovered
Knuth's third volume, Sorting and Searching (1973), gave the first rigorous, comprehensive treatment of external sorting — polyphase merges, balanced merges, and the buffer-allocation tradeoffs that determine how many runs can be merged per pass. This analysis became the direct ancestor of how database systems implement external sort for query processing, including the sort-merge join.
The join algorithm itself emerged as relational database systems matured in the 1970s and needed efficient equi-join evaluation without assuming indexes existed on every join column. Sort-merge join was one of the earliest join strategies implemented in production relational engines, alongside nested-loop join, and its cost model became a foundational component of early cost-based query optimizers.
Origins · 1 minhow this was discovered
Where this matters
Sort-merge join is a workhorse operator in essentially every mature relational database engine — PostgreSQL, Oracle, DB2, SQL Server — selected by the optimizer whenever both join inputs are large, no efficient index supports an index nested-loop join, and at least one input is already sorted or cheaply sortable (for instance, following an `ORDER BY`, a merge-friendly index scan, or a prior sort-based operator in the same plan). It is also the natural join strategy after a `GROUP BY`-then-sort or a range partition, since the sort has effectively already been paid for. In distributed and big-data engines — Apache Spark SQL, Hive, Presto/Trino — sort-merge join is the default strategy for large "shuffle joins": both tables are hash-partitioned by join key across worker machines, each partition is sorted locally, and a merge join runs within each partition, avoiding the memory blow-up that a distributed hash join would otherwise risk on skewed or oversized partitions. The same external-sort machinery underlies `ORDER BY` execution, `GROUP BY` aggregation via sorting, duplicate elimination in `DISTINCT`, and bulk index construction, making external merge sort one of the most reused primitives in the entire query execution engine. Understanding sort-merge join's cost model is also a direct prerequisite for reasoning about The Query Optimizer's decisions, since the optimizer must estimate and compare exactly the costs derived here against the alternative costs of Join Algorithms: Nested-Loop and hash-based joins before committing to a physical execution plan.
Ask the tutor: How does a query optimizer decide between sort-merge join, hash join, and nested-loop join for a specific query plan?
Why not just sort both relations with an ordinary in-memory sort like quicksort?
Ordinary in-memory sorts assume random access to all elements at roughly uniform cost, which holds only while data fits in RAM. Once a relation spans thousands of disk blocks, quicksort's random access pattern would thrash the disk with an enormous number of scattered I/Os, far worse than a bounded number of sequential passes. External merge sort is specifically designed around the disk's strength — fast sequential reads — organizing the work into a small number of full, sequential sweeps over the data, each doing useful merging work, which is why its I/O cost scales as rather than something disk-hostile.
How does sort-merge join compare to hash join in practice?
Hash join partitions relations by a hash of the join key and matches within partitions using an in-memory hash table, typically achieving similarly favorable -style I/O when partitions fit in memory, sometimes beating sort-merge when no ordering is otherwise needed. Sort-merge instead wins when the data is already sorted, when the optimizer also needs sorted output (for a later `ORDER BY` or `GROUP BY`), when the join key distribution is highly skewed (hash join can suffer from oversized partitions, while sort-merge degrades more gracefully), or when a non-equality comparison is required, since hash join fundamentally requires equality-based hashing.
What happens if one relation is already sorted on the join attribute but the other is not?
The optimizer sorts only the unsorted relation and skips the sort phase entirely for the one that is already ordered, roughly halving the total sort cost. This commonly occurs when the sorted relation has a clustering (sorted) index on the join column, letting the engine read it in sorted order for free via an index scan rather than paying for an explicit external sort. Recognizing pre-existing sort orders — and propagating "interesting orders" through a query plan — is a key optimization technique inside real query optimizers.
Can sort-merge join handle inequality joins, not just equi-joins?
Sort-merge join's simple, efficient two-pointer merge fundamentally relies on equality partitioning matching groups cleanly, so it is designed for equi-joins. Range or inequality joins (e.g., `r.A < s.B`) can, in some cases, still exploit sorted order — once `r.A ≥ s.B` for some pair, all subsequent `s` values with even larger B will also fail the condition for that r, allowing early pruning — but the matching logic becomes more intricate and is typically handled by specialized range-join or band-join variants rather than the standard merge-join operator.
Is the final merge pass always written back to disk before the join begins?
Not necessarily, and this is an important practical optimization. If the last merge pass of external sort produces runs that can be merged directly into the final sorted order needed for the join, the query engine can pipeline that last pass straight into the merge-join scan without materializing the fully sorted relation to disk first. This "sort-merge pipelining" saves one full read/write pass per relation, which the cost formulas in this article treat as a separate, avoidable pass in the worst case.
Why does the number of merge passes depend on log base (M−1) rather than log base M?
Because one of the M available buffer pages must be reserved as the output buffer during merging, leaving only pages available as input buffers — one per run being merged simultaneously. Since each merge pass can therefore combine at most runs into one, the number of runs shrinks by a factor of per pass, and the number of passes needed to collapse initial runs down to one is , not .
How does sort-merge join relate to nested-loop join in terms of when each is chosen?
Nested-loop join (especially the block-nested-loop or index-nested-loop variants) is often cheaper when one relation is small, an index exists on the join column of the other, or the join is highly selective, since it can avoid touching most of the larger relation. Sort-merge join wins when both relations are large, unindexed, and the join is not highly selective, because its cost scales near-linearly with the sum of relation sizes rather than their product, which is exactly the failure mode that makes plain nested-loop join impractical at scale.