studyDDIAChapter 11 · Batch Processing

An Aggregation's Memory Tracks Distinct Keys, Not Input Size

Count URL hits with an in-memory hash table (counts[url] += 1) and with sort | uniq -c. Hold the input at 2,000,000 lines and vary only the distinct keys: 2,000,000 identical lines cost the hash table one entry (20.8 MiB), but a million distinct keys cost 156.1 MiB — a 7.5× swing at the same line count. Over that same millionfold cardinality change, sort's memory moves just 1.34×.


Concept

Count how many times each URL appears in a log two ways: an in-memory hash table (counts[url] += 1) and the Unix pipeline sort | uniq -c | sort -rn | head. Both compute the same answer. Their memory does not scale with the same thing. Hold the input at 2,000,000 lines and vary only the number of distinct keys — 1, then 1,000, then 100,000, then 1,000,000. A file of 2,000,000 identical lines costs the hash table exactly one dict entry (20.8 MiB, essentially interpreter baseline); push the distinct keys to a million and the same 2,000,000-line input now costs 156.1 MiB — a 7.5× swing driven purely by cardinality. The sort step over that same millionfold change in distinct keys moves only 1.34× (264.4 → 354.4 MiB): its footprint is insensitive to how many distinct keys there are. A reader who expects "memory grows with the input" or "the hash table always needs less memory" has the wrong model.

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 11 — Batch Processing, §"Sorting Versus In-Memory Aggregation". The book contrasts two ways a batch job groups-and-aggregates records: build a hash table of the aggregation state in memory, or sort the records so equal keys become adjacent and aggregate in one streaming pass. Its guidance:

If the working set... is smaller than the amount of memory available, [a hash table] approach works fine... On the other hand, if the working set is larger than the available memory, the sorting approach has the advantage that it can make efficient use of disks.

The exercise makes "working set" concrete: it is the count of distinct keys, and you watch the hash table's memory track exactly that while the line count stays fixed.

Prerequisites

The surprise is about what the working set of a GROUP BY actually is. Each line notes where the idea shows up.

  1. Hash aggregation vs. external sort — the two GROUP BY strategies: keep one accumulator per group in a hash table, or sort so equal keys are adjacent and fold them in one pass. Shows up as the two ways the script counts the same file. (DDIA §"Sorting Versus In-Memory Aggregation".)
  2. Working set = distinct cardinality — the memory a hash aggregation needs is one slot per distinct key, i.e. the GROUP BY cardinality, not the number of input rows. Shows up as the hash-table column tracking the distinct-keys column while the line count is pinned at 2,000,000. This one carries the result. (Wikipedia, "Cardinality (SQL statements)".)
  3. External merge sort spills to disk — when the data exceeds RAM, sort reads it in chunks, sorts each chunk, writes sorted runs to disk, then merges them with a bounded buffer — so its memory is bounded by buffer size, not by input or cardinality. Shows up as the sort column staying flat in cardinality. (Wikipedia, "External sorting".)
  4. RSS measurement — resident set size is the physical RAM a process holds; resource.getrusage(RUSAGE_SELF).ru_maxrss reports its peak (on macOS in bytes). Running each aggregation in a fresh subprocess isolates its peak. (Python docs, resource module; man getrusage.)
Where to learn the prerequisites Hash vs. sort aggregation (#1): DDIA §"Sorting Versus In-Memory Aggregation"; and any database-internals note on hash-aggregate vs. sort-aggregate operators. Working set / cardinality (#2): DDIA's "working set" discussion — the single idea that, held against a fixed row count, explains the whole table. External sorting (#3): Wikipedia, "External sorting" — the K-way merge and why a fixed merge buffer bounds RAM. If only one is new, make it #2: the working set of an aggregation is its distinct-key count, and that is the number the hash table's RSS follows.
Environment these numbers came from macOS 26.5.2 (Darwin 25.5.0), arm64; Python 3.15.0a8, standard library only; BSD sort/uniq; no Docker; datasets are seeded and RSS is a real measurement (note: RSS varies slightly run-to-run and by allocator — the SCALING with distinct-key count is the reproducible result, exact bytes are approximate). ru_maxrss on macOS is in bytes; the script divides by 1024² and reports MiB. Each hash aggregation runs in its own subprocess so its ru_maxrss reflects just that run. BSD sort is measured under /usr/bin/time -l.

Mental model

Same file, two ways to group-and-count:

The whole thing is one script, code/sort_vs_hashtable.py: it generates seeded datasets of a fixed line count and varying distinct-key count, runs each hash aggregation in a subprocess, and measures sort under /usr/bin/time -l.

A measurement caveat, stated honestly macOS BSD sort mmaps its input, so its measured RSS reflects the mapped (file-backed, reclaimable) pages — bounded by the file's byte size, not by the distinct-key count. That is why its RSS here is a few hundred MiB and drifts up slightly with file size. It is not the coreutils spill-to-disk buffer, but it demonstrates the same load-bearing fact: sort's memory does not scale with cardinality. The reproducible result is the scaling, not the absolute bytes.

Setup

cd study/ddia/ch11/code
python3 sort_vs_hashtable.py

The script builds ~50–58 MiB datasets in a temp dir (auto-cleaned) and takes about a minute end to end. Do not read the output yet — make each prediction first.


Step 1 · 2,000,000 identical lines

Predict. The first dataset is 2,000,000 lines that are all the same URL (1 distinct key). The hash aggregation reads every line and does counts[url] += 1. Roughly how much peak RSS does that cost — does 2,000,000 records mean a big table?

How much RSS for 2,000,000 identical lines?
distinct keys lines input hash-table RSS sort RSS ------------- ----------- --------- ---------------- ------------ 1 2,000,000 49.6 MiB 20.8 MiB 264.4 MiB

20.8 MiB — essentially the bare interpreter. Two million identical lines collapse to a dict with one entry. The records streamed past; only the distinct key stayed resident. Memory is not proportional to input size.

Step 2 · hold the lines fixed, grow the distinct keys

Predict. Every row below is the same 2,000,000 lines. Only the distinct-key count changes: 1 → 1,000 → 100,000 → 1,000,000. Does the hash table's RSS follow the (constant) line count, or the (growing) distinct-key count — and by how much from top row to bottom?

Does hash RSS track the line count or the distinct keys?
Peak memory vs. distinct-key count (input fixed at 2,000,000 lines): distinct keys lines input hash-table RSS sort RSS ------------- ----------- --------- ---------------- ------------ 1 2,000,000 49.6 MiB 20.8 MiB 264.4 MiB 1,000 2,000,000 52.1 MiB 21.2 MiB 301.2 MiB 100,000 2,000,000 55.7 MiB 35.8 MiB 308.5 MiB 1,000,000 2,000,000 58.2 MiB 156.1 MiB 354.4 MiB hash table: 20.8 MiB for 1 distinct key -> 156.1 MiB for 1,000,000 distinct (7.5x) -- tracks DISTINCT KEYS, same line count.

The hash table follows the distinct-key column, not the line count. Line count is pinned at 2,000,000 and input size barely moves (49.6 → 58.2 MiB), yet RSS climbs 20.8 → 156.1 MiB (7.5×) — one resident dict slot per distinct key. The working set is the cardinality.

Step 3 · the sort pipeline over the same cardinality swing

Predict. The sort RSS column is BSD sort over the same four datasets. As the distinct keys span a millionfold range (1 → 1,000,000), does sort's memory climb like the hash table's, or stay roughly put?

Does sort's memory climb with cardinality too?
sort: 264.4 -> 354.4 MiB over the same millionfold cardinality swing (1.34x) -- insensitive to distinct-key count; its RSS tracks input bytes (BSD `sort` mmaps the file).

Sort barely moves — 1.34× against the hash table's 7.5×. Its memory does not track how many distinct groups there are; the small drift it does show tracks the input's byte size (BSD sort mmaps the file). Cardinality is the hash table's cost, not sort's.

Step 4 · do both methods actually agree?

Predict. On the 1,000-key dataset, the hash table and the pipeline sort | uniq -c | sort -rn | head should rank the same top URLs. Will their top-5 counts be identical, or can the two routes disagree?

Do the hash table and the pipeline give identical top-5 counts?
Both methods agree on the answer. Top 5 URLs of the 1,000-key dataset: hash: counts[url] += 1 sort | uniq -c | sort -rn | head -------------------------------- ---------------------------------- 200,322 0 200,322 0 51,931 1 51,931 1 36,539 2 36,539 2 28,954 3 28,954 3 24,460 4 24,460 4 identical top-5 counts: True

Identical. Same computation, two engines. The choice between them is never about the answer — it's about which resource each spends to get there.


What you should see

Why

Grouping-and-aggregating is, mechanically, maintaining one accumulator per group: for "count hits per URL," the accumulator is an integer and there is exactly one per distinct URL. That is the irreducible state of the computation — the set of running totals you cannot yet finalize. The question is only where that state lives.

Hash aggregation keeps every accumulator resident. The pass over the input is single, forward, and order-blind: at line n you cannot know whether key k will recur at line n+1, so every accumulator you have started must stay in the dict until the file ends. The resident state is therefore the set of distinct keys seen so far, which by the end is the full distinct-key set. This is why the line count drops out of the memory cost: a key that appears once and a key that appears two million times both occupy exactly one slot — counts[url] += 1 mutates a slot, it never adds one. Memory = |distinct keys| × (per-slot cost). Two million identical lines → one slot → 20.8 MiB. A million distinct keys → a million slots → 156.1 MiB. The 7.5× is the million slots (plus their key strings) made resident; the 2,000,000 line count never enters the arithmetic.

Sort-based aggregation replaces resident state with adjacency. If you first sort the lines, all copies of a key are contiguous, so uniq -c needs only one accumulator at a time: emit the count for the run that just ended, reset, move on. The distinct keys are never all live at once — they stream past finalized. The cost moves into the sort, and external merge sort is specifically the algorithm that bounds sort's RAM: chunk the input, sort each chunk in memory, spill sorted runs to disk, then K-way-merge them through a fixed buffer. RAM is governed by that buffer and the input's size, never by the number of distinct groups — which is why sort's column barely twitches (1.34×) while the hash table's swings 7.5× over the identical datasets. (On macOS the measured sort RSS is dominated by the mmapped input, which is bounded by file bytes for the same reason: still not cardinality.)

The boundary — when the hash table wins, it wins outright If the distinct keys fit comfortably in RAM, in-memory hash aggregation is both simpler and faster: one pass, no sort, no disk. Nothing here says "always sort" — at 1,000,000 distinct keys the hash table still used only 156 MiB and would beat sort on wall clock. The sort route earns its place only when the GROUP BY cardinality exceeds memory: that is the point where the hash table can no longer hold one slot per distinct key and would thrash or OOM, while external sort keeps streaming against disk. The deciding quantity is distinct-key count vs. available RAM — never the row count, which is exactly the intuition this exercise overturns.

Go deeper

Sources: DDIA 2e, Ch. 11, §"Sorting Versus In-Memory Aggregation" · Wikipedia, "External sorting" · Python resource module docs.