Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Structures & Algorithms (DSA)  Module 21 – Algorithms in practice

Data that does not fit: external sort, streams

Sort files larger than memory with sorted runs and a k-way heap merge, and summarise endless streams in one pass with counters, samples and sketches.

  • Advanced
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free core, Pro depth: 11 of 30 minutes free
Free: External merge sort in two phases · Practise: merge k sorted streams · One pass, bounded memory · Check yourself. With a pass: 2 in-depth parts and 3 interview answers, about 19 minutes.

What you will learn

  • Sort data larger than memory with external merge sort
  • Summarise streams in one pass with bounded memory
  • Choose exact or approximate summaries

Before you start

Some data never fits in memory: a day of logs larger than the machine’s RAM, or a stream of events that never ends. Algorithms that load everything first fail on both, and two families of techniques take over. External algorithms work through files in a few sequential passes; the most important is external merge sort, which sorts memory-sized chunks into sorted files called runs and then merges all the runs at once with a heap. Streaming algorithms read each item once and keep a summary whose size does not grow with the stream: exact counters while the keys fit in memory, and approximate summaries, random samples and sketches, when they do not. This lesson runs both on real files and generated streams.

External merge sort in two phases

Suppose a 200 MB log must be sorted by time on a machine that can spare 20 MB. External merge sort does it in two phases:

  1. Runs. Read 20 MB of lines, sort them in memory and write them to a file, a sorted run. Repeat until the input ends: 10 runs.
  2. Merge. Open all 10 runs and keep the next unwritten line of each in a min-heap of 10 entries. Write the smallest line, then refill the heap from the run it came from. When every run is used up, the output is sorted.
External merge sort: a 200 MB file becomes 10 sorted runs of 20 MB, which a heap merges into one sorted file.Unsorted file: 200 MBPhase 1, ten times:read 20 MB, sort it in memory,write it out as a sorted run10 sorted runs of 20 MB eachPhase 2: a min-heap holdsthe next line of each run;write the smallest, refill from its runSorted file: 200 MBpass 1: read and write everythingpass 2: read and write everything

External merge sort of 200 MB with 20 MB of memory

Text description of the diagram

The diagram is a column of five boxes joined by arrows from top to bottom.

  1. An unsorted file of 200 MB.
  2. Phase 1, done ten times: read 20 MB, sort it in memory and write it out as a sorted run. The arrow into this step says that pass 1 reads and writes everything once.
  3. Ten sorted runs of 20 MB each.
  4. Phase 2: a min-heap holds the next line of each run; the smallest line is written to the output and replaced by the next line of the same run. The arrow into this step says that pass 2 reads and writes everything once more.
  5. The sorted file of 200 MB.

Each phase reads all the data once and writes it once, so the sort moves 4 × 200 MB = 800 MB through the disk, whatever the lines say. Disks and SSDs read long sequential stretches far faster than scattered blocks, and sorting in memory is quick next to that, so the number of passes over the data decides the time. The merge holds only one line of each run, plus a read buffer per file, so its memory grows with the number of runs, not with the size of the data. Python’s heapq.merge performs phase 2: its documentation says it does not pull the data into memory all at once.

External merge sort of 50,000 lines in runs of 5,000 Python · core/external_sort.py
# External merge sort on a small scale: sort a log file of 50,000 lines while holding at most 5,000 lines in memory.
# Phase 1 cuts the file into sorted runs; phase 2 merges the runs with a heap (heapq.merge reads them lazily).
import heapq
import itertools
import os
import random

LINES = 50_000
BUDGET = 5_000  # the most lines phase 1 holds in memory at once


def make_log(path, n, seed):
    """A synthetic access log: the millisecond of the day, the service and a request id."""
    rng = random.Random(seed)
    services = ["auth", "cart", "pay", "search", "ship"]
    with open(path, "w") as f:
        for _ in range(n):
            f.write(f"{rng.randrange(86_400_000):08d} {rng.choice(services):6} req{rng.randrange(10**6):06d}\n")


def make_runs(path, budget):
    """Phase 1: read `budget` lines at a time, sort them in memory, write each batch as a run file."""
    runs = []
    with open(path) as f:
        while chunk := list(itertools.islice(f, budget)):
            chunk.sort()
            name = f"run{len(runs):03d}.txt"
            with open(name, "w") as out:
                out.writelines(chunk)
            runs.append(name)
    return runs


def merge_runs(runs, out_path):
    """Phase 2: one k-way merge; heapq.merge keeps one line of each run in a heap."""
    files = [open(name) for name in runs]
    try:
        with open(out_path, "w") as out:
            out.writelines(heapq.merge(*files))
    finally:
        for f in files:
            f.close()


make_log("access.log", LINES, seed=3)
size = os.path.getsize("access.log")
runs = make_runs("access.log", BUDGET)
run_bytes = sum(os.path.getsize(r) for r in runs)
merge_runs(runs, "sorted.log")

with open("access.log") as f:
    expected = sorted(f)  # only to check the result: this small file fits in memory
with open("sorted.log") as f:
    result = f.readlines()
print(f"input: {LINES:,} lines, {size:,} bytes")
print(f"phase 1: {len(runs)} runs of at most {BUDGET:,} lines, each sorted in memory")
print(f"phase 2: one {len(runs)}-way merge, holding one line of each run in a heap")
print(f"bytes read: {size + run_bytes:,} (the data twice); bytes written: {run_bytes + os.path.getsize('sorted.log'):,}")
print(f"first lines: {result[0].strip()} | {result[1].strip()}")
print(f"output sorted and complete: {result == expected}")
for name in runs + ["access.log", "sorted.log"]:
    os.remove(name)

Output

input: 50,000 lines, 1,300,000 bytes
phase 1: 10 runs of at most 5,000 lines, each sorted in memory
phase 2: one 10-way merge, holding one line of each run in a heap
bytes read: 2,600,000 (the data twice); bytes written: 2,600,000
first lines: 00002015 cart   req217182 | 00002550 pay    req798609
output sorted and complete: True

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 external_sort.py

The run sorted 50,000 lines while never holding more than 5,000 of them, read the data twice and wrote it twice, and the result matches Python’s sorted() line for line. With 10 runs the heap had 10 entries; a file of any size would give the same shape, with more or longer runs.

Practise: merge k sorted streams

Exercise · Medium · Python, JavaScript

Merge k sorted streams lazily with a heap

Phase 2 of an external merge sort reads k sorted runs at once and writes one sorted output, holding only a little of each run in memory. Write that merge as a generator (in JavaScript, a generator function mergeSorted):

  • merge_sorted(inputs) takes a list of iterators (or iterables), each already sorted from smallest to largest, and yields every item of all of them in sorted order.
  • It must be lazy: it reads an input only when it needs that input's next item, and holds at most a couple of items from each input at a time. The tests pass inputs that never end, so reading everything first never finishes.
  • Equal items come out in input order: an item of an earlier input before an equal item of a later one.

Write the heap yourself with heapq.heappush and heapq.heappop (not heapq.merge); in JavaScript, keep the k fronts in an array and take the smallest, or write a small binary heap. For example, merging [1, 4, 9], [2, 3, 10] and [] yields 1, 2, 3, 4, 9, 10.

Python · Starter code · merge.py

import heapq


def merge_sorted(inputs):
    """Yield the items of the sorted iterators in `inputs` in sorted order, reading each one lazily."""
    return iter([])
The sample tests · test_merge.py
from itertools import count, islice

from merge import merge_sorted


def test_three_inputs():
    """merges three sorted lists, one of them empty"""
    assert list(merge_sorted([iter([1, 4, 9]), iter([2, 3, 10]), iter([])])) == [1, 2, 3, 4, 9, 10]


def test_no_inputs():
    """no inputs give no output"""
    assert list(merge_sorted([])) == []


def test_duplicates_and_negatives():
    """keeps every copy of a repeated item"""
    assert list(merge_sorted([iter([-5, 0, 0, 7]), iter([0, 7, 7])])) == [-5, 0, 0, 0, 7, 7, 7]


def test_strings():
    """works for anything that can be compared, such as log lines"""
    out = merge_sorted([iter(["0815 pay", "0930 cart"]), iter(["0816 auth"]), iter(["0001 ship", "0999 pay"])])
    assert list(out) == ["0001 ship", "0815 pay", "0816 auth", "0930 cart", "0999 pay"]


def test_equal_items_in_input_order():
    """an item of an earlier input comes before an equal item of a later one"""
    a, b = [1.0, 2.0], [1, 2]  # 1 == 1.0, but the two can be told apart by type
    out = list(merge_sorted([iter(a), iter(b)]))
    assert out == [1.0, 1, 2.0, 2] and [type(x) for x in out] == [float, int, float, int]


def test_lazy_with_endless_inputs():
    """reads only what it needs: these inputs never end"""
    merged = merge_sorted([count(0, 3), count(1, 3), count(2, 3)])
    assert list(islice(merged, 12)) == list(range(12))


def test_holds_little_of_each_input():
    """has read at most two items of an input when it yields the first item"""
    pulled = [0, 0]

    def tracked(items, i):
        for x in items:
            pulled[i] += 1
            yield x

    merged = merge_sorted([tracked([1, 2, 3, 4], 0), tracked([10, 20], 1)])
    assert next(merged) == 1
    assert pulled[0] <= 2 and pulled[1] == 1
    assert list(merged) == [2, 3, 4, 10, 20]

JavaScript · Starter code · merge.mjs

/** Yield the items of the sorted iterables in `inputs` in sorted order, reading each one lazily. */
export function* mergeSorted(inputs) {
  yield* [];
}
The sample tests · merge.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { mergeSorted } from './merge.mjs';

function* countFrom(start, step) {
  for (let x = start; ; x += step) yield x;
}
function take(iterator, n) {
  const out = [];
  for (const x of iterator) {
    if (out.length === n) break;
    out.push(x);
  }
  return out;
}

test('merges three sorted lists, one of them empty', () => {
  assert.deepEqual([...mergeSorted([[1, 4, 9], [2, 3, 10], []])], [1, 2, 3, 4, 9, 10]);
});
test('no inputs give no output', () => {
  assert.deepEqual([...mergeSorted([])], []);
});
test('keeps every copy of a repeated item', () => {
  assert.deepEqual([...mergeSorted([[-5, 0, 0, 7], [0, 7, 7]])], [-5, 0, 0, 0, 7, 7, 7]);
});
test('works for anything that can be compared, such as log lines', () => {
  const out = [...mergeSorted([['0815 pay', '0930 cart'], ['0816 auth'], ['0001 ship', '0999 pay']])];
  assert.deepEqual(out, ['0001 ship', '0815 pay', '0816 auth', '0930 cart', '0999 pay']);
});
test('reads only what it needs: these inputs never end', () => {
  const merged = mergeSorted([countFrom(0, 3), countFrom(1, 3), countFrom(2, 3)]);
  assert.deepEqual(take(merged, 12), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
});
test('has read at most two items of an input when it yields the first item', () => {
  const pulled = [0, 0];
  function* tracked(items, i) {
    for (const x of items) {
      pulled[i]++;
      yield x;
    }
  }
  const merged = mergeSorted([tracked([1, 2, 3, 4], 0), tracked([10, 20], 1)]);
  assert.equal(merged.next().value, 1);
  assert.ok(pulled[0] <= 2 && pulled[1] === 1);
  assert.deepEqual([...merged], [2, 3, 4, 10, 20]);
});
A hint

Put one entry per input in the heap: (item, input_number, iterator). The input number breaks ties, so equal items come out in input order and two iterators are never compared. Pop the smallest entry, yield its item, then read the next item of the same iterator and push it back; when that iterator is exhausted, push nothing.

The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

One pass, bounded memory

A stream is data you see once, in order, as it arrives: requests to a server, sensor readings, clicks. A streaming algorithm updates a small summary for each item and can answer at any moment. Some questions have exact one-pass answers in constant memory: the count, the sum and the mean, the minimum and the maximum, and the variance with Welford’s method, which updates a running mean and a running sum of squared differences.

A million response times, five numbers kept Python · core/one_pass.py
# One pass over a stream of a million response times, keeping five numbers: count, mean, the sum of squared
# differences from the mean (Welford's method, for the standard deviation), minimum and maximum. The stream is a
# generator, so no time is ever stored, and the memory used does not grow with the stream.
import math
import random


def response_times(n, seed):
    """A stream of n response times in milliseconds: at least 5 ms, mostly fast, with a slow tail."""
    rng = random.Random(seed)
    for _ in range(n):
        yield 5 + rng.expovariate(1 / 40) + (rng.random() < 0.02) * rng.uniform(500, 2000)


count = 0
mean = 0.0
m2 = 0.0  # the sum of squared differences from the current mean
low = math.inf
high = -math.inf
for t in response_times(1_000_000, seed=5):
    count += 1
    delta = t - mean
    mean += delta / count
    m2 += delta * (t - mean)
    low = min(low, t)
    high = max(high, t)

print(f"times seen: {count:,}, numbers kept: 5")
print(f"mean {mean:.2f} ms, standard deviation {math.sqrt(m2 / (count - 1)):.2f} ms")
print(f"fastest {low:.2f} ms, slowest {high:,.1f} ms")

Output

times seen: 1,000,000, numbers kept: 5
mean 70.24 ms, standard deviation 190.24 ms
fastest 5.00 ms, slowest 2,287.6 ms

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 one_pass.py

Other questions need memory that grows with the data if the answer must be exact. The number of distinct users needs every user seen so far, the most frequent terms need a counter for every term, and the median needs every value. When that memory is not available, a summary of fixed size answers approximately:

  • Exact counters and hash maps give exact answers, with memory that grows with the number of distinct keys.
  • Reservoir sampling keeps a uniform random sample of k items from a stream of unknown length.
  • The Misra–Gries algorithm keeps k − 1 counters and never loses an item that makes up more than 1/k of the stream.
  • A count-min sketch estimates how often any item appeared from a fixed table of counters, and is never too low.
  • A Bloom filter answers “seen before?” in a few bits per item, with false positives but no false negatives.
  • HyperLogLog estimates the number of distinct items from a few kilobytes, typically within 1.04/√m for m registers.
Exact state, a sample or a sketch?
Criterion Exact hash map or setRandom sampleSketch (Misra–Gries, count-min, HyperLogLog)
Answers Anything about the keys, exactlyQuestions about typical items: averages, sharesOne kind of question each: frequent items, counts, distinct counts
Memory Grows with the distinct keysFixed: k itemsFixed: a table chosen from the error you accept
Error NoneSampling error, shrinking like 1/√kBounded, one-sided or relative, with a stated probability
Several machines Merge the maps, if they fitPossible, with weightsMerge counter by counter or register by register
When to choose The keys fit in memoryExploring or estimating shares of a huge streamFixed memory with a guaranteed error

Check yourself

Check yourself

4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 4 A 120 GB file is sorted by external merge sort with 6 GB of memory for each run. How many sorted runs does phase 1 write?

    Type a number.

    Show the answer to question 1

    Answer: 20 runs

    Phase 1 fills the memory, sorts it and writes it out, so each run holds 6 GB: 120 ÷ 6 = 20 runs. Phase 2 then merges all 20 at once with a heap of 20 entries.

  2. Question 2 of 4 Why does the merge phase of external merge sort need so little memory?

    Choose one answer.

    Show the answer to question 2

    Answer: It holds only the next line of each run, with a read buffer per run, however long the runs are

    A k-way merge only ever needs the smallest unwritten line of each run, so its heap has k entries. Its memory grows with the number of runs, not with the size of the data. Merging two runs at a time also works but needs many more passes over the data.

  3. Question 3 of 4 Which of these can a one-pass algorithm compute exactly, in constant memory, from a stream of numbers?

    Choose one answer.

    Show the answer to question 3

    Answer: The mean

    The mean needs only a running count and sum (or Welford's running mean). The median, the distinct count and the most frequent value can depend on every value seen so far, so an exact answer needs memory that grows with the stream; approximate summaries answer them from a fixed size.

  4. Question 4 of 4 A count-min sketch says a search term appeared 1,250 times. What do you know for certain about its true count?

    Choose one answer.

    Show the answer to question 4

    Answer: It is at most 1,250

    Every counter a term touches holds all of that term's own arrivals, and other terms that share a counter only add to it, so a count-min estimate is never below the true count. It can be too high, by at most εN with probability at least 1 − δ.

External merge sort at full size, with numbers Pro: opens with a Pro, Premium or Ultimate pass

The small run showed the two phases. Here a 200 MB file is sorted with a 20 MB budget and every byte read and written is counted, then two techniques cut the passes for data far larger than memory.

In this part:

  • Sorting 200 MB with a 20 MB budget, measured
  • Fewer, longer runs with replacement selection
  • Passes, fan-in and what they cost, worked out

3 runnable examples (Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Streaming summaries measured: samples, heavy hitters, distinct counts Pro: opens with a Pro, Premium or Ultimate pass

Each approximate summary comes with a guarantee. This part checks three of them on generated streams, reproduces the classic mistake in sampling, and ends with a table for choosing between exact and approximate answers.

In this part:

  • Reservoir sampling, its invariant, and a biased imitation
  • Heavy hitters with k − 1 counters, measured
  • Distinct counts from 4 KB, measured
  • Choosing exact or approximate, with numbers

3 runnable examples (Python) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    What does external merge sort do in each of its two phases, and why is it used?

    It sorts data larger than memory. Phase 1 reads as much data as fits in memory, sorts it there and writes it out as a sorted file called a run, again and again until the input is used up. Phase 2 merges all the runs at once: a min-heap holds the next item of each run, the smallest is written to the output and replaced by the next item of the same run. Each phase reads and writes all the data once, and disks read long sequential stretches quickly, so the cost is about two passes over the data, and the merge needs memory for one buffer per run rather than for the data.

  2. Mid to senior

    You must sort a 500 GB file of 64-bit customer IDs on a machine with 16 GB of RAM and an SSD that reads and writes about 1 GB/s. How many runs and passes will external merge sort make, how much data moves, and roughly how long will it take?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Senior to staff

    Forty servers each log 250 million requests a day. Report the 100 client IP addresses with the most requests, using a few megabytes of memory per server and one central job. Which summary do you keep, what can go wrong, and how accurate is the result?

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    Pick 1,000 events uniformly at random from a stream whose length you do not know in advance, in one pass and with memory for 1,000 events. Give the algorithm and prove that every event has the same chance of being chosen.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • External merge sort writes memory-sized sorted runs, then merges them with a heap holding one item per run; each pass reads and writes all the data, so passes decide the cost.
  • A merge needs memory for one buffer per run, not for the data, and heapq.merge reads its inputs lazily.
  • Streaming algorithms see each item once and keep a summary that does not grow with the stream: exact when the answer allows it, such as counts, means and extremes.
  • When exact state does not fit, samples and sketches answer approximately from a fixed size, each with a stated error.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.