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.

Key takeaways
  • 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 MM memory buffer pages, external sort produces ⌈b/M⌉\lceil b/M \rceil initial runs and needs ⌈log⁡M−1(b/M)⌉\lceil \log_{M-1}(b/M) \rceil merge passes.
  • Total sort-merge join I/O cost is O(brlog⁡Mbr+bslog⁡Mbs)O(b_r \log_M b_r + b_s \log_M b_s) 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.
The core insight

Sort both relations on the join key, then a single synchronized linear scan finds every matching pair — turning an O(nm)O(nm) matching problem into an O(nlog⁡n+mlog⁡m)O(n \log n + m \log m) one.

The idea

A sort-merge join (also called merge join) evaluates an equi-join r⋈r.A=s.Bsr \bowtie_{r.A = s.B} s by first sorting both rr and ss on their join attributes AA and BB, 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 rr and ss are both sorted on the join attribute, all tuples of rr and ss with a given join value vv 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 MM buffer pages available in memory, read MM pages of the relation at a time, sort them internally (e.g., quicksort or heapsort, O(Mlog⁡M)O(M \log M) per chunk), and write the sorted chunk to disk as one run. A relation of bb blocks yields ⌈b/M⌉\lceil b/M \rceil initial sorted runs, each of length ≤M\le M blocks. Merge passes. Runs are merged in groups. With MM buffer pages, M−1M-1 can serve as input buffers (one page per input run being merged) and 1 page serves as the output buffer, so up to M−1M-1 runs are merged at a time into one longer run. This halves — more precisely, divides by M−1M-1 — the number of runs each pass. Passes repeat until a single run remains.

Phase 2 — Merge join. Once rr and ss 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.

Figure
External sort (run generation + merge passes) feeding into merge-join
Phase 1: External Merge Sort of relation R (b = 8 blocks, M = 3) Pass 0 — run generation (chunks of M=3 blocks, sorted in memory) Run A (3 blk) Run B (3 blk) Run C (2) Pass 1 — merge up to (M−1)=2 runs at a time Run AB (6 blk) Run C (2) Pass 2 — final merge → fully sorted run R fully sorted (8 blk) (same process sorts S) S fully sorted (6 blk) (runs / passes analogous, omitted for space) Phase 2: Merge-Join — two sorted streams, one coordinated scan 10 20 20 30 R: 10 20 20 40 S: key 20 = 20 → group match: R has 2 tuples, S has 2 tuples → emit 2×2 = 4 joined rows

Complexity & correctness

External sort cost. Let bb be the number of blocks in a relation and MM the number of memory buffer pages available.

  • Run generation reads and writes every block once: 2b2b block transfers, producing ⌈b/M⌉\lceil b/M \rceil initial runs.
  • Each merge pass also reads and writes every block once: 2b2b transfers per pass. A pass merges groups of up to M−1M - 1 runs at a time, so the number of runs shrinks by a factor of M−1M-1 each pass. Starting from ⌈b/M⌉\lceil b/M \rceil runs, the number of passes needed to reach 11 run is
⌈log⁡M−1(b/M)⌉.\lceil \log_{M-1} (b/M) \rceil .
  • Total transfers for sorting one relation:
costsort(b)=2b(1+⌈log⁡M−1(b/M)⌉)=O(blog⁡Mb).\text{cost}_{\text{sort}}(b) = 2b \left( 1 + \lceil \log_{M-1}(b/M) \rceil \right) = O(b \log_M b).

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 br+bsb_r + b_s transfers. Total:

costsort-merge=O ⁣(brlog⁡Mbr+bslog⁡Mbs).\text{cost}_{\text{sort-merge}} = O\!\big(b_r \log_M b_r + b_s \log_M b_s\big).

This is near-linear in the relation sizes, in sharp contrast to block nested-loop join's O(br⋅bs/M)O(b_r \cdot b_s / M) when no index or sorted order is available. For very large br,bsb_r, b_s relative to MM, sort-merge is asymptotically far superior; the crossover point in practice depends on MM, since log⁡Mb\log_M b shrinks quickly as MM grows (larger memory buffers mean fewer, wider merge passes).

Special case — one merge pass suffices. If b/M≤M−1b/M \le M - 1 (equivalently b≤M(M−1)≈M2b \le M(M-1) \approx M^2), the entire relation can be sorted with a single run-generation pass followed by a single merge pass: two passes total, cost 4b4b. 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 "O(b)O(b)" in practical settings even though the general bound is O(blog⁡Mb)O(b\log_M b).

Correctness of run generation. Each chunk of ≤M\le M 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 (M−1)(M-1)-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 (r′,s′)(r', s') with r′.A=s′.Br'.A = s'.B and (r′,s′)(r', s') 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 ss back to `s_group_start` for each new rr in the matching group, only the first rr-tuple in the group would be matched against all ss-tuples in the group, silently losing (∣R-group∣−1)×∣S-group∣(|R\text{-group}| - 1) \times |S\text{-group}| 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 O(M)O(M) memory (a constant number of buffer pages), independent of bb — 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 O(g)O(g) extra buffer pages where gg 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 M=3M = 3 pages.

Sorting R (8 blocks, M = 3):

PassAction# runs before# runs afterI/O this pass
0 (run gen)read 3-block chunks, sort in memory, write—⌈8/3⌉=3\lceil 8/3 \rceil = 32×8=162 \times 8 = 16
1 (merge)merge up to M−1=2M-1=2 runs at a time3⌈3/2⌉=2\lceil 3/2 \rceil = 22×8=162 \times 8 = 16
2 (merge)merge remaining 2 runs212×8=162 \times 8 = 16

Total for sorting R: 16+16+16=4816 + 16 + 16 = 48 block transfers, matching 2b(1+⌈log⁡2(8/3)⌉)=2(8)(1+2)=482b(1 + \lceil \log_2(8/3)\rceil) = 2(8)(1+2)=48.

Sorting S (5 blocks, M = 3):

Pass# runs before# runs afterI/O this pass
0 (run gen)—⌈5/3⌉=2\lceil 5/3\rceil = 22×5=102\times5=10
1 (merge)212×5=102\times5=10

Total for sorting S: 2020 transfers.

Merge-join phase: one additional linear pass over each sorted relation (assuming the sorted runs are materialized on disk rather than pipelined): 8+5=138 + 5 = 13 transfers.

Grand total: 48+20+13=8148 + 20 + 13 = 81 block transfers. Compare to block nested-loop join with the same M=3M=3 (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

bs+⌈bsM−2⌉⋅br=5+⌈5/1⌉⋅8=5+40=45.b_s + \left\lceil \frac{b_s}{M-2}\right\rceil \cdot b_r = 5 + \lceil 5/1 \rceil \cdot 8 = 5 + 40 = 45.

At this tiny scale, nested-loop actually wins because MM is too small for sort-merge's per-relation-size logarithm to pay off — a reminder that asymptotic superiority (O(blog⁡Mb)O(b\log_M b) vs. O(brbs/M)O(b_r b_s/M)) only dominates once relations are large relative to MM. Re-running the comparison at br=8000b_r = 8000, bs=5000b_s=5000, M=100M=100 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, R.A=[10,20,20,30,45]R.A = [10, 20, 20, 30, 45], S.B=[15,20,20,20,50]S.B = [15, 20, 20, 20, 50]:

Stepr pointers pointerComparisonAction
1101510<1510 < 15advance r
2201520>1520 > 15advance s
32020equal — group match beginspin `s_group_start` at first 20
420 (1st)20,20,20inner loopemit (20,20)×3, advance s through group
520 (2nd)rewind to `s_group_start`inner loop againemit (20,20)×3 again, advance s
6305030<5030<50advance r
7455045<5045<50advance r → end of R

Result: 2×3=62 \times 3 = 6 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

PitfallWhy it happensFix
Forgetting the group rewind for duplicate keysTreating merge-join like simple sorted-list intersectionBuffer the matching group on one side and replay it for each match on the other side
Assuming sort cost is O(b)O(b) alwaysTrue only when b≤M(M−1)b \le M(M-1) (one merge pass)Use the general O(blog⁡Mb)O(b \log_M b) formula for large bb relative to MM
Ignoring buffer allocation (M−1M-1 inputs + 1 output)Off-by-one in run-merge fan-inReserve exactly one page per input run plus one output page per merge step
Materializing sorted runs to disk when pipelining is possibleTreating sort and join as fully separate phasesFeed the last merge pass of the sort directly into the join scan, saving one full read/write pass
PitfallWhy it happensFix
Choosing sort-merge for tiny, highly selective joinsIgnoring that sorting cost is paid regardless of selectivityCompare 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 orderVerify 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
DE
Donald E. Knuth
1938–present · Stanford University, author of "The Art of Computer Programming"

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
History
1973
— Knuth formalizes external merge sort (polyphase and balanced merge strategies) in The Art of Computer Programming, Vol. 3.
1976
— IBM's System R project implements sort-merge join as one of its core join strategies, alongside nested-loop join.
1979
— System R's optimizer paper (Selinger et al.) formalizes cost-based selection between nested-loop and sort-merge join strategies.
1980
s — Commercial relational systems (DB2, Oracle, Ingres) adopt sort-merge join as a standard operator for large, unindexed equi-joins.
2000
s–present — Sort-merge join remains a standard operator in distributed query engines (Hive, Spark SQL, Presto), often as the default strategy for large-table shuffle joins.

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 O(blog⁡Mb)O(b\log_M b) 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 O(blog⁡Mb)O(b\log_M b) 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 O(br+bs)O(b_r + b_s)-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 M−1M-1 pages available as input buffers — one per run being merged simultaneously. Since each merge pass can therefore combine at most M−1M-1 runs into one, the number of runs shrinks by a factor of M−1M-1 per pass, and the number of passes needed to collapse b/Mb/M initial runs down to one is ⌈log⁡M−1(b/M)⌉\lceil \log_{M-1}(b/M)\rceil, not log⁡M\log_M.

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.

Before this

Where this leads