Join algorithms and the memory threshold
Measure your machineNeeds Python with DuckDB
Problem
A hash join builds a hash table on the smaller input and probes it with the larger. When the build side does not fit in its memory budget, a spilling join partitions both inputs to disk and joins partition by partition, so time rises gradually as more of the build side spills: a ramp, not a cliff. Below some budget even the partitions do not fit, and the engine either recurses or gives up.
In DuckDB, join a 20-million-row table to a 5-million-row table (with a 32-character text payload) on a sparse integer key. Run with memory_limit at 4 GB, then lower it step by step to 100 MB, each in a fresh process, recording time and the temp space used. Also run with 1 thread against 8.
The question. How does join time respond as the memory limit falls, and where does it stop working?
Predict
Expected: time rises gently as the build side begins to spill (a ramp, not a cliff), and then more steeply when memory is very small.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
join-algorithms-and-the-memory-threshold.py # /// script # requires-python = ">=3.10" # dependencies = ["duckdb"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Join algorithms and the memory threshold. DuckDB joins a 20-million-row table to a 5-million-row table (with a text payload) on an integer key, under memory limits from 4 GB down to 100 MB, each in a fresh process, recording the time and the temp space used. Then threads=1 against threads=8 at the largest limit. """ import argparse, json, os, shutil, subprocess, sys, tempfile p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--probe", type=int, default=20_000_000, help="Rows in the probe (large) table.") p.add_argument("--build", type=int, default=5_000_000, help="Rows in the build (smaller) table.") p.add_argument("--limits", default="4GB,1GB,500MB,300MB,200MB,100MB", help="memory_limit values.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() CHILD = r""" import duckdb, json, os, sys, threading, time db, tmp, limit, threads = sys.argv[1:5] c = duckdb.connect(db, read_only=True); c.execute(f"SET memory_limit='{limit}'"); c.execute(f"SET temp_directory='{tmp}'"); c.execute(f"SET threads={threads}"); c.execute("SET enable_progress_bar=false") peak = [0]; stop = [False] def watch(): while not stop[0]: peak[0] = max(peak[0], sum(os.path.getsize(os.path.join(d, f)) for d, _, fs in os.walk(tmp) for f in fs if os.path.exists(os.path.join(d, f)))) time.sleep(0.05) threading.Thread(target=watch, daemon=True).start() q = "SELECT count(*), sum(b.v), sum(length(b.pad)) FROM big a JOIN small b ON a.k = b.k" # the build side carries a payload t = time.perf_counter() try: c.execute(q).fetchall(); ok = "ok" except Exception as e: ok = str(e).splitlines()[0][:100] el = time.perf_counter() - t; stop[0] = True print(json.dumps({"seconds": el, "temp_mb": peak[0] / 2**20, "outcome": ok})) """ work = tempfile.mkdtemp(); db = os.path.join(work, "j.duckdb") import duckdb c = duckdb.connect(db); c.execute("SELECT setseed(0.1)") c.execute(f"CREATE TABLE small AS SELECT i * 7919 AS k, i * 2 AS v, md5(i::VARCHAR) AS pad FROM range({args.build}) t(i)") # sparse keys: no perfect hash c.execute(f"CREATE TABLE big AS SELECT (random() * {args.build})::BIGINT * 7919 AS k, i AS w FROM range({args.probe}) t(i)"); c.close() def run(limit, threads): tmp = os.path.join(work, "tmp"); shutil.rmtree(tmp, ignore_errors=True); os.makedirs(tmp) return json.loads(subprocess.run([sys.executable, "-c", CHILD, db, tmp, limit, str(threads)], capture_output=True, text=True, check=True).stdout.strip().splitlines()[-1]) rows = [{"memory_limit": l, "threads": 8, **run(l, 8)} for l in args.limits.split(",")] first = args.limits.split(",")[0] threads = [{"threads": 1, **run(first, 1)}, {"threads": 8, **next({k: v for k, v in r.items() if k not in ("memory_limit", "threads")} for r in rows if r["memory_limit"] == first)}] shutil.rmtree(work, ignore_errors=True) for r in rows: print(f"memory_limit {r['memory_limit']:>6}: {r['seconds']:6.2f} s temp {r['temp_mb']:7.0f} MB {r['outcome']}") for r in threads: print(f"threads={r['threads']}: {r['seconds']:.2f} s") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"limits": rows, "threads": threads}}, f)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 join-algorithms-and-the-memory-threshold.py uv run join-algorithms-and-the-memory-threshold.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --probe20,000,000 | Rows in the probe (large) table. |
| --build5,000,000 | Rows in the build (smaller) table. |
| --limits4GB, 1GB, 500MB, 300MB, 200MB, 100MB | memory_limit values. |
Our run
Data table
| memory_limit | Time (s) |
|---|---|
| — | 0.305 |
| — | 0.313 |
| — | 0.383 |
| — | 0.452 |
| — | 0.539 |
| — | 0.123 |
Memory limits (8 threads)
| memory_limit | Time | Temp (MB) | Outcome |
|---|---|---|---|
| 4GB | 305 ms | 0 | ok |
| 1GB | 313 ms | 0 | ok |
| 500MB | 383 ms | 155 | ok |
| 300MB | 452 ms | 389 | ok |
| 200MB | 539 ms | 488 | ok |
| 100MB | 123 ms | 138 | Out of Memory Error: could not allocate block of size 256.0 KiB (95.4 MiB/95.3 MiB used) |
Threads (4 GB)
| Threads | Time |
|---|---|
| 1 | 949 ms |
| 8 | 305 ms |
memory_limit 4GB: 0.31 s temp 0 MB ok
memory_limit 1GB: 0.31 s temp 0 MB ok
memory_limit 500MB: 0.38 s temp 155 MB ok
memory_limit 300MB: 0.45 s temp 389 MB ok
memory_limit 200MB: 0.54 s temp 488 MB ok
memory_limit 100MB: 0.12 s temp 138 MB Out of Memory Error: could not allocate block of size 256.0 KiB (95.4 MiB/95.3 MiB used)
threads=1: 0.95 s
threads=8: 0.31 sWhat we found
With 4 GB or 1 GB the join ran in memory in 305 ms. From 500 MB it spilled, and time rose gently with the spill: 383 ms with 155 MB of temp files, 539 ms with 488 MB at 200 MB, a ramp as expected. At 100 MB the query failed: Out of Memory Error: could not allocate block of size 256.0 KiB (95.4 MiB/95.3 MiB used). One thread took 949 ms against 305 ms for eight.
Read it
Graceful spilling turns a memory shortfall into extra I/O proportional to the shortfall, which is why join performance degrades smoothly until a hard floor, where the engine can no longer hold even one partition. Size memory grants above that floor, and watch temp-file volume as the early signal.