Skip to content
About
Main content

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

Finding the top 10 of 20 million numbers, what does the bounded heap peak at in memory?

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.

    top-k-with-a-bounded-heap.py61 linesDownload
    View 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)
    
  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 top-k-with-a-bounded-heap.py
    uv run top-k-with-a-bounded-heap.py --help     # every knob
Knobs
Knob · defaultWhat 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

Top 10 of the file
MethodTimePeak memory (MB)
sort -nr | head34.3 s1,280
bounded heap (heapq.nlargest)2.73 s0.246

DuckDB

DuckDB
Plan operatorQuery time
TOP_N258 ms
Reference runApple M2 · 8 cores · 8 GB
Output · uv run top-k-with-a-bounded-heap.py
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)
macOS 26.6.2 · Python 3.13.3 · duckdb 1.5.6 · run 2026-10-20

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