Top-k with a bounded heap versus a full sort
Measure your machineNeeds Python, sort
Problem
Finding the k largest of n values does not need the other n − k in order. A full sort costs O(n log n) time and holds all n values; a bounded heap of size k keeps only the current top k, compares each new value against the smallest of them, and costs O(n log k) time and O(k) memory. For k = 10 the heap is a few hundred bytes whatever n is. Analytical engines know this and replace ORDER BY … LIMIT k with a top-N operator.
Generate random numbers in a file. Compute the top 10 with (a) sort -nr | head -10 and (b) a bounded heap (heapq.nlargest). Record the time and the peak memory. Then run ORDER BY x LIMIT 10 in DuckDB and look at the plan (EXPLAIN) to check that it does not do a full sort.
The run uses 20 million numbers instead of 100 million so that the full sort fits on an 8 GB laptop.
The question. How much time and memory does the bounded heap save over a full sort, and does DuckDB sort at all?
Predict
Expect (b) to use a few kilobytes of memory and (a) to use gigabytes or spill to disk.
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
top-k-with-a-bounded-heap.py # /// script # requires-python = ">=3.10" # dependencies = ["duckdb"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Top-k with a bounded heap. A file of random numbers; the top 10 by `sort -nr | head -10` and by a bounded heap (heapq.nlargest): time and peak memory of each. Then DuckDB's plan for ORDER BY x LIMIT 10. """ import argparse, heapq, json, os, random, resource, subprocess, sys, tempfile, time import duckdb p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--numbers", type=int, default=20_000_000, help="Numbers in the file (the chapter uses 100 million).") p.add_argument("--k", type=int, default=10, help="k.") p.add_argument("--path", default=os.path.join(tempfile.gettempdir(), "topk-lab.txt"), help="The numbers file.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() import atexit, shutil atexit.register(lambda: shutil.rmtree(args.path, ignore_errors=True) if os.path.isdir(args.path) else os.path.exists(args.path) and os.remove(args.path)) # leave nothing behind random.seed(0) if not os.path.exists(args.path) or os.path.getsize(args.path) < args.numbers * 5: with open(args.path, "w") as f: for _ in range(args.numbers // 100_000): f.write("\n".join(str(random.randrange(10**12)) for _ in range(100_000)) + "\n") def child_peak_mb(): r = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss return r / 1e6 if sys.platform == "darwin" else r / 1e3 # bytes on macOS, KB on Linux t = time.perf_counter() out = subprocess.run(f"sort -nr {args.path} | head -{args.k}", shell=True, capture_output=True, text=True).stdout.split() t_sort, m_sort = time.perf_counter() - t, child_peak_mb() heap_code = f"import heapq,resource,sys;print(heapq.nlargest({args.k}, map(int, open({args.path!r}))));" \ "r=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss;print(r/1e6 if sys.platform=='darwin' else r/1e3)" t = time.perf_counter() res = subprocess.run([sys.executable, "-c", heap_code], capture_output=True, text=True).stdout.split("\n") t_heap, m_heap = time.perf_counter() - t, float(res[1]) base = subprocess.run([sys.executable, "-c", "import resource,sys;r=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss;print(r/1e6 if sys.platform=='darwin' else r/1e3)"], capture_output=True, text=True).stdout m_heap_extra = m_heap - float(base) con = duckdb.connect() plan = con.execute(f"EXPLAIN SELECT * FROM read_csv('{args.path}', columns={{'x': 'BIGINT'}}, header=false) ORDER BY x DESC LIMIT {args.k}").fetchall() plan_text = " ".join(r[1] for r in plan) op = "TOP_N" if "TOP_N" in plan_text else ("ORDER_BY" if "ORDER" in plan_text else "?") t = time.perf_counter(); con.execute(f"SELECT * FROM read_csv('{args.path}', columns={{'x': 'BIGINT'}}, header=false) ORDER BY x DESC LIMIT {args.k}").fetchall() t_duck = time.perf_counter() - t rows = [{"method": "sort -nr | head", "seconds": t_sort, "peak_mb": m_sort}, {"method": "bounded heap (heapq.nlargest)", "seconds": t_heap, "peak_mb": m_heap_extra}] for r in rows: print(f"{r['method']:<30} {r['seconds']:7.1f} s peak memory {r['peak_mb']:8.1f} MB") print(f"DuckDB plan for ORDER BY ... LIMIT {args.k}: {op} operator ({t_duck:.1f} s)") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"methods": rows, "summary": [{"duckdb_op": op, "duckdb_s": t_duck, "numbers": args.numbers}]}}, 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 top-k-with-a-bounded-heap.py uv run top-k-with-a-bounded-heap.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --numbers20,000,000 | Numbers in the file (the chapter uses 100 million). |
| --k10 | k. |
| --pathos.path.join(tempfile.gettempdir(), 'topk-lab.txt') | The numbers file. |
Our run
Top 10 of the file
| Method | Time | Peak memory (MB) |
|---|---|---|
| sort -nr | head | 34.3 s | 1,280 |
| bounded heap (heapq.nlargest) | 2.73 s | 0.246 |
DuckDB
| Plan operator | Query time |
|---|---|
| TOP_N | 258 ms |
sort -nr | head 34.3 s peak memory 1282.9 MB
bounded heap (heapq.nlargest) 2.7 s peak memory 0.2 MB
DuckDB plan for ORDER BY ... LIMIT 10: TOP_N operator (0.3 s)What we found
sort -nr | head took 34.3 s and peaked at 1,280 MB; the bounded heap took 2.73 s and peaked at 0.246 MB, a few hundred kilobytes, most of it the reading buffer. DuckDB's plan used a TOP_N operator, not a sort, and answered in 258 ms.
Read it
The heap wins on memory by the ratio n/k, not by a constant: the sort needs every value at once and the heap never holds more than ten. That is the difference between a query that runs in a fixed budget and one that spills when the input grows.
Check the plan rather than trusting the optimizer: a top-N operator appears only when the limit is a constant and nothing between the sort and the limit forces full order (a window function over the result, for example, does).