A sort produces a property, not an answer
What does an external sort really cost, and how does a distributed sort turn a global merge into a concatenation?
Sorting is the only operator here that computes nothing. It adds no information, removes no rows and changes no values. Its output is a permutation of its input. It exists for one purpose: to supply a physical property (order) that some other operator requires. That makes it the purest example of the idea that has run through this mechanism: operators are cheap; enforcers are the query; and sort is an enforcer with no operator attached.
Two consequences follow.
- The best implementation of a sort is not running it. That is why "sort avoidance through interesting orders" is not the last item in this article but its conclusion.
- Because sorting computes nothing, its cost depends on nothing semantic. A join's cost depends on key frequencies. An aggregation's cost depends on NDV. These are the two least reliable statistics in any catalogue. A sort's cost depends only on N (data size), M (memory) and B (I/O block size). It is the only operator here whose cost you can compute in advance and be right. A plan with a sort is boring, and boring is the scarcest property in query execution.
The core tension: amortization across requirements
Hash gives you co-location (equal keys end up together) at linear cost and leaves nothing behind. Sort gives you co-location and order, at a log factor, and the order composes: one enforcement can satisfy a merge join, then an aggregation, then an ORDER BY.
So the trade is not "which algorithm is faster". It is amortization across requirements:
- One consumer of the order: hash wins on even terms.
- Three consumers: hash pays three times, while sort paid once.
Extend the count from "operators in this query" to "all future queries", and you get clustering, bucketing, LSM ordering (the sorted files of a log-structured merge tree) and indexes. It is the same arithmetic with a much larger base, paid at write time. This is where physical design either collects on its investment or finds that it bought the wrong order.
External merge sort: not a log problem
An external sort sorts data larger than memory. It has two phases.
- Run generation. Fill M bytes of memory, sort them in place, write the sorted block (a run) to disk. Repeat. This produces N/M runs.
- Merge. Read one block from each of F runs, merge them, and write the output. F is bounded by memory divided by block size: F = M/B, because each input run needs a buffer of one block, and the output needs one too.
The number of passes over the data is 1 + ⌈log_F(N/M)⌉. A single merge pass is enough when N/M ≤ F, which means
- M / B (blocks of memory)
- — how many runs are merged at once
Data table
| Data ÷ memory N/M | series | Passes |
|---|---|---|
| 1 | F = 10 (old disks) | 1 |
| 1.26 | F = 10 (old disks) | 2 |
| 1.58 | F = 10 (old disks) | 2 |
| 2 | F = 10 (old disks) | 2 |
| 2.51 | F = 10 (old disks) | 2 |
| 3.16 | F = 10 (old disks) | 2 |
| 3.98 | F = 10 (old disks) | 2 |
| 5.01 | F = 10 (old disks) | 2 |
| 6.31 | F = 10 (old disks) | 2 |
| 7.94 | F = 10 (old disks) | 2 |
| 10 | F = 10 (old disks) | 2 |
| 12.6 | F = 10 (old disks) | 3 |
| 15.8 | F = 10 (old disks) | 3 |
| 20 | F = 10 (old disks) | 3 |
| 25.1 | F = 10 (old disks) | 3 |
| 31.6 | F = 10 (old disks) | 3 |
| 39.8 | F = 10 (old disks) | 3 |
| 50.1 | F = 10 (old disks) | 3 |
| 63.1 | F = 10 (old disks) | 3 |
| 79.4 | F = 10 (old disks) | 3 |
| 100 | F = 10 (old disks) | 3 |
| 126 | F = 10 (old disks) | 4 |
| 158 | F = 10 (old disks) | 4 |
| 200 | F = 10 (old disks) | 4 |
| 251 | F = 10 (old disks) | 4 |
| 316 | F = 10 (old disks) | 4 |
| 398 | F = 10 (old disks) | 4 |
| 501 | F = 10 (old disks) | 4 |
| 631 | F = 10 (old disks) | 4 |
| 794 | F = 10 (old disks) | 4 |
| 1K | F = 10 (old disks) | 4 |
| 1.26K | F = 10 (old disks) | 5 |
| 1.58K | F = 10 (old disks) | 5 |
| 2K | F = 10 (old disks) | 5 |
| 2.51K | F = 10 (old disks) | 5 |
| 3.16K | F = 10 (old disks) | 5 |
| 3.98K | F = 10 (old disks) | 5 |
| 5.01K | F = 10 (old disks) | 5 |
| 6.31K | F = 10 (old disks) | 5 |
| 7.94K | F = 10 (old disks) | 5 |
| 10K | F = 10 (old disks) | 5 |
| 12.6K | F = 10 (old disks) | 6 |
| 15.8K | F = 10 (old disks) | 6 |
| 20K | F = 10 (old disks) | 6 |
| 25.1K | F = 10 (old disks) | 6 |
| 31.6K | F = 10 (old disks) | 6 |
| 39.8K | F = 10 (old disks) | 6 |
| 50.1K | F = 10 (old disks) | 6 |
| 63.1K | F = 10 (old disks) | 6 |
| 79.4K | F = 10 (old disks) | 6 |
| 100K | F = 10 (old disks) | 6 |
| 126K | F = 10 (old disks) | 7 |
| 158K | F = 10 (old disks) | 7 |
| 200K | F = 10 (old disks) | 7 |
| 251K | F = 10 (old disks) | 7 |
| 316K | F = 10 (old disks) | 7 |
| 398K | F = 10 (old disks) | 7 |
| 501K | F = 10 (old disks) | 7 |
| 631K | F = 10 (old disks) | 7 |
| 794K | F = 10 (old disks) | 7 |
| 1M | F = 10 (old disks) | 7 |
| 1.26M | F = 10 (old disks) | 8 |
| 1.58M | F = 10 (old disks) | 8 |
| 2M | F = 10 (old disks) | 8 |
| 2.51M | F = 10 (old disks) | 8 |
| 3.16M | F = 10 (old disks) | 8 |
| 3.98M | F = 10 (old disks) | 8 |
| 5.01M | F = 10 (old disks) | 8 |
| 6.31M | F = 10 (old disks) | 8 |
| 7.94M | F = 10 (old disks) | 8 |
| 10M | F = 10 (old disks) | 8 |
| 12.6M | F = 10 (old disks) | 9 |
| 15.8M | F = 10 (old disks) | 9 |
| 20M | F = 10 (old disks) | 9 |
| 25.1M | F = 10 (old disks) | 9 |
| 31.6M | F = 10 (old disks) | 9 |
| 39.8M | F = 10 (old disks) | 9 |
| 50.1M | F = 10 (old disks) | 9 |
| 63.1M | F = 10 (old disks) | 9 |
| 79.4M | F = 10 (old disks) | 9 |
| 100M | F = 10 (old disks) | 9 |
| 1 | F = 100 | 1 |
| 1.26 | F = 100 | 2 |
| 1.58 | F = 100 | 2 |
| 2 | F = 100 | 2 |
| 2.51 | F = 100 | 2 |
| 3.16 | F = 100 | 2 |
| 3.98 | F = 100 | 2 |
| 5.01 | F = 100 | 2 |
| 6.31 | F = 100 | 2 |
| 7.94 | F = 100 | 2 |
| 10 | F = 100 | 2 |
| 12.6 | F = 100 | 2 |
| 15.8 | F = 100 | 2 |
| 20 | F = 100 | 2 |
| 25.1 | F = 100 | 2 |
| 31.6 | F = 100 | 2 |
| 39.8 | F = 100 | 2 |
| 50.1 | F = 100 | 2 |
| 63.1 | F = 100 | 2 |
| 79.4 | F = 100 | 2 |
| 100 | F = 100 | 2 |
| 126 | F = 100 | 3 |
| 158 | F = 100 | 3 |
| 200 | F = 100 | 3 |
| 251 | F = 100 | 3 |
| 316 | F = 100 | 3 |
| 398 | F = 100 | 3 |
| 501 | F = 100 | 3 |
| 631 | F = 100 | 3 |
| 794 | F = 100 | 3 |
| 1K | F = 100 | 3 |
| 1.26K | F = 100 | 3 |
| 1.58K | F = 100 | 3 |
| 2K | F = 100 | 3 |
| 2.51K | F = 100 | 3 |
| 3.16K | F = 100 | 3 |
| 3.98K | F = 100 | 3 |
| 5.01K | F = 100 | 3 |
| 6.31K | F = 100 | 3 |
| 7.94K | F = 100 | 3 |
| 10K | F = 100 | 3 |
| 12.6K | F = 100 | 4 |
| 15.8K | F = 100 | 4 |
| 20K | F = 100 | 4 |
| 25.1K | F = 100 | 4 |
| 31.6K | F = 100 | 4 |
| 39.8K | F = 100 | 4 |
| 50.1K | F = 100 | 4 |
| 63.1K | F = 100 | 4 |
| 79.4K | F = 100 | 4 |
| 100K | F = 100 | 4 |
| 126K | F = 100 | 4 |
| 158K | F = 100 | 4 |
| 200K | F = 100 | 4 |
| 251K | F = 100 | 4 |
| 316K | F = 100 | 4 |
| 398K | F = 100 | 4 |
| 501K | F = 100 | 4 |
| 631K | F = 100 | 4 |
| 794K | F = 100 | 4 |
| 1M | F = 100 | 4 |
| 1.26M | F = 100 | 5 |
| 1.58M | F = 100 | 5 |
| 2M | F = 100 | 5 |
| 2.51M | F = 100 | 5 |
| 3.16M | F = 100 | 5 |
| 3.98M | F = 100 | 5 |
| 5.01M | F = 100 | 5 |
| 6.31M | F = 100 | 5 |
| 7.94M | F = 100 | 5 |
| 10M | F = 100 | 5 |
| 12.6M | F = 100 | 5 |
| 15.8M | F = 100 | 5 |
| 20M | F = 100 | 5 |
| 25.1M | F = 100 | 5 |
| 31.6M | F = 100 | 5 |
| 39.8M | F = 100 | 5 |
| 50.1M | F = 100 | 5 |
| 63.1M | F = 100 | 5 |
| 79.4M | F = 100 | 5 |
| 100M | F = 100 | 5 |
| 1 | F = 8,192 (NVMe, 1 MB buffers) | 1 |
| 1.26 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 1.58 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 2 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 2.51 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 3.16 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 3.98 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 5.01 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 6.31 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 7.94 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 10 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 12.6 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 15.8 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 20 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 25.1 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 31.6 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 39.8 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 50.1 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 63.1 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 79.4 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 100 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 126 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 158 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 200 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 251 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 316 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 398 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 501 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 631 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 794 | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 1K | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 1.26K | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 1.58K | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 2K | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 2.51K | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 3.16K | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 3.98K | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 5.01K | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 6.31K | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 7.94K | F = 8,192 (NVMe, 1 MB buffers) | 2 |
| 10K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 12.6K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 15.8K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 20K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 25.1K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 31.6K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 39.8K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 50.1K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 63.1K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 79.4K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 100K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 126K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 158K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 200K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 251K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 316K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 398K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 501K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 631K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 794K | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 1M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 1.26M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 1.58M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 2M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 2.51M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 3.16M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 3.98M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 5.01M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 6.31M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 7.94M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 10M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 12.6M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 15.8M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 20M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 25.1M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 31.6M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 39.8M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 50.1M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 63.1M | F = 8,192 (NVMe, 1 MB buffers) | 3 |
| 79.4M | F = 8,192 (NVMe, 1 MB buffers) | 4 |
| 100M | F = 8,192 (NVMe, 1 MB buffers) | 4 |
Worked examples
| Memory M | Block size B | One merge pass handles up to M²/B |
|---|---|---|
| 1 GB | 1 MB | 1 GB × 1 GB / 1 MB = 1 GB × 1,000 = 1 TB |
| 16 GB | 8 MB | 16 GB × 16 GB / 8 MB = 16 GB × 2,000 = 32 TB |
(Check the second line: 16 GB / 8 MB = 2,000, so 16 GB × 2,000 = 32,000 GB = 32 TB.) In a distributed sort each node handles only N/P, so the per-node input is smaller again.
The honest statement about external sorting is therefore: the logarithm is a constant 2, and it has been 2 for thirty years, because memory M and block size B grew together. External sort is not a complexity problem. It is a bandwidth problem of exactly 4N: read N, write N as runs, read N, write N as output. Discussions of its asymptotics discuss a term that never changes.
Where it does change: the block size. On object storage (cloud storage where each request has a latency of tens of milliseconds and a price), B must be 8 to 16 MB to amortize the per-request fixed cost. A larger B reduces the fan-in F for the same memory, which lowers M²/B and brings the third pass closer. The fixed cost of an I/O operation determines the block size; the block size determines the fan-in; the fan-in determines the number of passes.
Two corrections to the standard account
Fan-in should not be maximized. An F-way merge costs about log₂ F comparisons per record in the merge phase. With F = 1,000 that is about ten comparisons per record, comparable to the entire in-memory sort's per-record cost. Raising the fan-in to remove a pass saves I/O but spends CPU. The optimum is not at the maximum. (A loser tree, a tournament structure for picking the next smallest record, improves the constant but not the log.)
Replacement selection is a correct optimization for a cost model that no longer binds. Replacement selection keeps a heap in memory and emits the smallest key that is not smaller than the last key emitted. On random input it makes runs of average length 2M instead of M, which halves the number of runs. But it destroys sequential locality: a heap of M elements is a random-access structure, whereas a modern in-memory sort is cache-friendly and can use vector instructions. And halving the number of runs almost never changes a pass count that is already two. Engines mostly abandoned it, correctly. What survives is its adaptive property: on nearly-sorted input it produces arbitrarily long runs, and the sort collapses toward a single pass. The technique is obsolete as a general optimization and alive as an adaptivity mechanism. Remember this whenever an old algorithm looks replaced.
The bottleneck moved
With a 4N I/O budget against NVMe drives that deliver several GB/s, and in-memory comparison sorting of string keys with collation (language-aware ordering rules) running at tens of MB/s per core, a sort is now often CPU-bound instead of I/O-bound. This is the reverse of the picture in every textbook that teaches the pass formula. That inversion is the reason for the section on normalized keys below.
Sample-based range-partitioned sort
A distributed sort must produce a global order across P machines. There are two shapes.
- Sort locally, then merge globally. The global merge either goes through one node or pushes all data through a merge tree.
- Range-partition first, then sort locally. Each record goes to the machine that owns its key range. Each machine sorts its share. The global result is the concatenation of the parts.
The second wins for a structural reason: range partitioning turns a global merge into a concatenation. Hash partitioning cannot do this. Hash gives co-location without order, so the parts do not compose. This is the sharpest demonstration that hashing and ordering are not two interchangeable ways to group things: one produces a result that composes and the other does not.
Choosing the splitters
The difficulty is choosing the splitters (the key values that divide the ranges). Equal partitions need knowledge of the key distribution, and knowing it needs reading the data. The solution is to sample: draw s records, sort the sample, and take its quantiles as splitters.
- The sample size needed to keep partition imbalance within a factor (1+ε) grows roughly like P/ε². Tightening the balance tolerance is quadratically expensive in sample size, the same square-root law as before. In practice, a few hundred samples per partition is the working rule.
- This is the standard case of randomization replaces coordination. Exact splitters would need a full pass and a global reduction. A random sample replaces that coordination entirely and pays with a bounded imbalance.
Three failure modes
1. Range partitioning does not fix skew. It fixes distributional skew, a key space where values cluster unevenly, because splitters can be placed densely where the data is dense. It does nothing for a point mass. If one key occurs more than N/P times, no splitters can balance the partitions: a splitter can only fall between distinct values, and one value is an atom of the partitioning. This is the same dead end that hash partitioning reached in the join article, approached from the other side. "Switch to range partitioning" is not an answer to a hot key.
2. A biased sample gives correct-looking splitters and a broken sort. If the input is already ordered (time-partitioned files read in order are the normal case), then taking the first s records samples one end of the key space. All splitters are wrong in the same direction. The failure is a badly unbalanced sort, not an error message. Systematic or random sampling is not a refinement here; it is the correctness condition.
3. Sampling introduces a barrier. The partition boundaries must exist before any record can be routed. A distributed sort is therefore at least two passes over the data (or one pass over a sampled subset plus one full pass), and the second cannot start before the first ends. This is the same structural cost that sideways information passing paid in the join article: converting independent work into dependent work.