Skip to content
About
Main content

Sort versus hash under a hot key

Measure your machineNeeds Python with DuckDB

Problem

Hash partitioning sends all rows of a key to the same partition, so a key holding 30% of the rows makes one partition hold at least 30%, and recursively re-partitioning cannot split it. For (SUM, COUNT) that does not matter: each group keeps a tiny running state however many rows it has. For (median) the group must hold all its values, and the hot key's values are most of the data.

Generate 50 million rows (meter_id, kwh) where 30% of the rows have meter_id = 0 and the rest are uniform over 1 million ids. Use DuckDB with a 500 MB memory limit. Run SELECT meter_id, SUM(kwh), COUNT(*) ... GROUP BY meter_id and then SELECT meter_id, median(kwh) ... GROUP BY meter_id, watching peak memory and the temp directory. Then split the rows into 16 hash partitions under 5 different hash seeds and print the size of the largest.

The question. What memory and do the algebraic and the holistic aggregation need, and can any hash seed split the hot key?


Predict

Across five hash seeds, what share of the data lands in the largest of 16 partitions?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    sort-versus-hash-under-a-hot.py81 linesDownload
    View the code
    sort-versus-hash-under-a-hot.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["duckdb", "numpy"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Sort versus hash under a hot key.
    
    50 million (meter_id, kwh) rows, 30% of them with meter_id = 0. DuckDB with a
    500 MB memory limit runs an algebraic aggregation (SUM, COUNT) and a holistic
    one (median) by meter_id; each query runs in its own process, which reports
    its peak memory and the most temp-directory space it saw. Then a grace-style
    hash partitioner splits the rows into 16 partitions under 5 hash seeds and
    prints the largest partition's share.
    """
    import argparse, json, os, shutil, subprocess, sys, tempfile
    import numpy as np
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--rows", type=int, default=50_000_000, help="Rows.")
    p.add_argument("--hot-share", type=float, default=0.3, help="Share of rows with meter_id = 0.")
    p.add_argument("--memory-limit", default="500MB", help="DuckDB memory_limit.")
    p.add_argument("--partitions", type=int, default=16, help="Partitions in the grace partitioner.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    CHILD = r"""
    import duckdb, json, os, resource, sys, threading, time
    db, tmp, limit, sql = sys.argv[1:5]
    peak_tmp = [0]; stop = [False]
    def watch():
        while not stop[0]:
            size = sum(os.path.getsize(os.path.join(dp, f)) for dp, _, fs in os.walk(tmp) for f in fs if os.path.exists(os.path.join(dp, f)))
            peak_tmp[0] = max(peak_tmp[0], size); time.sleep(0.05)
    c = duckdb.connect(db, read_only=True)
    c.execute(f"SET memory_limit='{limit}'"); c.execute(f"SET temp_directory='{tmp}'"); c.execute("SET threads=4"); c.execute("SET enable_progress_bar=false")
    threading.Thread(target=watch, daemon=True).start()
    t = time.perf_counter(); out = {"ok": True}
    try: n = len(c.execute(sql).fetchall())
    except Exception as e: out = {"ok": False, "error": str(e).splitlines()[0][:120]}; n = 0
    out.update(seconds=time.perf_counter() - t, groups=n); stop[0] = True; time.sleep(0.06)
    rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    out.update(peak_mb=rss / (2**20 if sys.platform == "darwin" else 2**10), temp_mb=peak_tmp[0] / 2**20)
    print(json.dumps(out))
    """
    work = tempfile.mkdtemp(); db = os.path.join(work, "t.duckdb")
    import duckdb
    c = duckdb.connect(db)
    c.execute(f"""CREATE TABLE t AS SELECT CASE WHEN random() < {args.hot_share} THEN 0 ELSE 1 + (random() * 999999)::INT END AS meter_id,
                  random() * 10 AS kwh FROM range({args.rows})""")
    hot = c.execute("SELECT count(*) FROM t WHERE meter_id = 0").fetchone()[0]; c.close()
    
    rows = []
    for kind, sql in (("algebraic: SUM, COUNT", "SELECT meter_id, SUM(kwh), COUNT(*) FROM t GROUP BY meter_id"),
                      ("holistic: median", "SELECT meter_id, median(kwh) FROM t GROUP BY meter_id")):
        tmp = os.path.join(work, "tmp"); shutil.rmtree(tmp, ignore_errors=True); os.makedirs(tmp)
        r = json.loads(subprocess.run([sys.executable, "-c", CHILD, db, tmp, args.memory_limit, sql], capture_output=True, text=True, check=True).stdout.strip().splitlines()[-1])
        rows.append({"query": kind, **r, "outcome": "ok" if r["ok"] else r["error"]})
    
    # grace-style partitioner: partition = hash(meter_id, seed) mod P
    ids = duckdb.connect(db, read_only=True).execute("SELECT meter_id FROM t").fetchnumpy()["meter_id"].astype(np.uint64)
    def h(x, seed):
        z = x + np.uint64(seed) * np.uint64(0x9E3779B97F4A7C15)
        z = (z ^ (z >> np.uint64(30))) * np.uint64(0xBF58476D1CE4E5B9); z = (z ^ (z >> np.uint64(27))) * np.uint64(0x94D049BB133111EB)
        return z ^ (z >> np.uint64(31))
    parts = []
    with np.errstate(over="ignore"):
        for seed in range(5):
            sizes = np.bincount((h(ids, seed) % np.uint64(args.partitions)).astype(np.int64), minlength=args.partitions)
            parts.append({"seed": seed, "largest_pct": sizes.max() / len(ids) * 100, "smallest_pct": sizes.min() / len(ids) * 100,
                          "even_pct": 100 / args.partitions})
    shutil.rmtree(work, ignore_errors=True)
    summary = {"hot_rows": hot, "hot_pct": hot / args.rows * 100, "memory_limit": args.memory_limit}
    print(f"{args.rows:,} rows, {hot:,} ({summary['hot_pct']:.1f}%) with meter_id = 0; DuckDB memory_limit {args.memory_limit}")
    for r in rows: print(f"{r['query']:<24} {r['seconds']:6.2f} s   peak memory {r['peak_mb']:7.0f} MB   temp space {r['temp_mb']:7.0f} MB   {r['outcome']}")
    for r in parts: print(f"hash seed {r['seed']}: largest of {args.partitions} partitions holds {r['largest_pct']:.1f}% of the rows (even split {r['even_pct']:.1f}%)")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"queries": rows, "partitions": parts}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run sort-versus-hash-under-a-hot.py
    uv run sort-versus-hash-under-a-hot.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--rows50,000,000 Rows.
--hot-share0.3 Share of rows with meter_id = 0.
--memory-limit500MB DuckDB memory_limit.
--partitions16 Partitions in the grace partitioner.

Our run

Two aggregations, 500 MB limit

Two aggregations, 500 MB limit
AggregationTimePeak memory (MB)Temp space (MB)Outcome
algebraic: SUM, COUNT3.67 s702949ok
holistic: median10.7 s1.42K1.5Kok

Largest of 16 hash partitions

Largest of 16 hash partitions
SeedLargestEven share
034.4%6.25%
134.4%6.25%
234.4%6.25%
334.4%6.25%
434.4%6.25%
Reference runApple M2 · 8 cores · 8 GB
Output · uv run sort-versus-hash-under-a-hot.py
50,000,000 rows, 15,002,773 (30.0%) with meter_id = 0; DuckDB memory_limit 500MB
algebraic: SUM, COUNT      3.67 s   peak memory     702 MB   temp space     949 MB   ok
holistic: median          10.73 s   peak memory    1420 MB   temp space    1495 MB   ok
hash seed 0: largest of 16 partitions holds 34.4% of the rows (even split 6.2%)
hash seed 1: largest of 16 partitions holds 34.4% of the rows (even split 6.2%)
hash seed 2: largest of 16 partitions holds 34.4% of the rows (even split 6.2%)
hash seed 3: largest of 16 partitions holds 34.4% of the rows (even split 6.2%)
hash seed 4: largest of 16 partitions holds 34.4% of the rows (even split 6.2%)
macOS 26.6.2 · Python 3.13.3 · duckdb 1.5.6 · numpy 2.5.4 · run 2026-10-20

What we found

The algebraic query peaked at 702 MB with 949 MB of temp space; the median query peaked at 1.42K MB with 1.5K MB of temp space, in 10.7 s against 3.67 s. Both finished, and both spilled: DuckDB's hash aggregation over a million groups exceeds 500 MB even with constant state per group, so "little or no spill" did not hold for the algebraic query here. Under every seed the largest partition held about 34.4% of the rows, against an even share of 6.25%: the hot key goes wherever its hash sends it, whole.


Read it

Skew is harmless to aggregates whose state is constant per group and decisive for those whose state grows with the group. When a holistic aggregate meets a hot key, change the aggregate (an approximate quantile sketch has constant state) or treat the hot key separately.