Skip to content
About
Main content

Join algorithms and the memory threshold

Measure your machineNeeds Python with DuckDB

Problem

A hash builds a on the smaller input and probes it with the larger. When the does not fit in its memory budget, a spilling join both inputs to disk and joins partition by partition, so time rises gradually as more of the build side : 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

Dropping the memory limit from 1 GB to 200 MB, so the hash join's build side spills: what happens to join time?

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.

    join-algorithms-and-the-memory-threshold.py58 linesDownload
    View 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)
    
  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 join-algorithms-and-the-memory-threshold.py
    uv run join-algorithms-and-the-memory-threshold.py --help     # every knob
Knobs
Knob · defaultWhat 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

Join time and temp space against the memory limitApple M2 · 8 cores · 8 GB
Data table
Join time and temp space against the memory limit
memory_limitTime (s)
—0.305
—0.313
—0.383
—0.452
—0.539
—0.123
macOS 26.6.2 · Python 3.13.3 · duckdb 1.5.6 · run 2026-10-20

Memory limits (8 threads)

Memory limits (8 threads)
memory_limitTimeTemp (MB)Outcome
4GB305 ms0ok
1GB313 ms0ok
500MB383 ms155ok
300MB452 ms389ok
200MB539 ms488ok
100MB123 ms138Out of Memory Error: could not allocate block of size 256.0 KiB (95.4 MiB/95.3 MiB used)

Threads (4 GB)

Threads (4 GB)
ThreadsTime
1949 ms
8305 ms
Output · uv run join-algorithms-and-the-memory-threshold.py
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 s

What 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.