Top-N heap against full sort
Run a real systemNeeds Docker, Python
Problem
The top N of a large input does not need a full sort: a heap of size N keeps the best N seen so far, using O(N) memory. Databases do the same with a top-N operator. A LIMIT also changes how a planner costs a plan: it may choose to walk an index in the requested order and stop after N matches, which is cheap if matches are spread evenly through the index and expensive if they all sit at the end.
Generate 20 million random numbers. Time sorted(data)[-100:], then heapq.nlargest(100, data), and record the peak memory of each. Then try a correlated case in a database: create a table where created_at and id grow together, run WHERE flag = 'rare' ORDER BY id LIMIT 10 where all rare rows are at the end, and compare the plan with and without the LIMIT.
The question. How much time and memory does the heap save, and what does the LIMIT do to the plan when the rare rows are all at the end?
Predict
Expect similar or better time for the heap, and a large drop in peak memory (O(k) against O(n)).
Run it
1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.
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-n-heap-against-full-sort.py # /// script # requires-python = ">=3.10" # dependencies = ["psycopg[binary]"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Top-N: heap against full sort, and a LIMIT that backfires. Part 1: the 100 largest of 20 million random numbers with sorted(data)[-100:] and with heapq.nlargest(100, data), each in its own process so that its peak memory can be read. Part 2 (PostgreSQL): a table where created_at and id grow together and all 'rare' rows are at the end; WHERE flag = 'rare' ORDER BY id LIMIT 10, with and without the LIMIT. Needs the PostgreSQL service from the compose file (port 55707). """ import argparse, json, re, resource, subprocess, sys import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55707/postgres", help="Connection string.") p.add_argument("--n", type=int, default=20_000_000, help="Random numbers.") p.add_argument("--rows", type=int, default=5_000_000, help="Rows in the PostgreSQL table.") p.add_argument("--rare", type=int, default=5_000, help="Rare rows, all at the end of the table.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() CHILD = r""" import heapq, random, resource, sys, time random.seed(0); data = [random.random() for _ in range(int(sys.argv[2]))] base = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss t = time.perf_counter() top = sorted(data)[-100:] if sys.argv[1] == "sort" else heapq.nlargest(100, data) el = time.perf_counter() - t peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss unit = 2**20 if sys.platform == "darwin" else 2**10 print(el, (peak - base) / unit) """ methods = [] for m, label in (("sort", "sorted(data)[-100:]"), ("heap", "heapq.nlargest(100, data)")): el, extra = map(float, subprocess.run([sys.executable, "-c", CHILD, m, str(args.n)], capture_output=True, text=True, check=True).stdout.split()) methods.append({"method": label, "seconds": el, "extra_peak_mb": extra}) c = psycopg.connect(args.dsn, autocommit=True) c.execute("DROP TABLE IF EXISTS events") c.execute(f"""CREATE TABLE events AS SELECT g AS id, TIMESTAMP '2025-01-01' + g * INTERVAL '1 second' AS created_at, CASE WHEN g > {args.rows} - {args.rare} THEN 'rare' ELSE 'common' END AS flag FROM generate_series(1, {args.rows}) g""") c.execute("ALTER TABLE events ADD PRIMARY KEY (id)"); c.execute("ANALYZE events") plans = [] for label, q in (("with LIMIT 10", "SELECT * FROM events WHERE flag = 'rare' ORDER BY id LIMIT 10"), ("without LIMIT", "SELECT * FROM events WHERE flag = 'rare' ORDER BY id")): txt = "\n".join(r[0] for r in c.execute("EXPLAIN (ANALYZE) " + q).fetchall()) top = txt.splitlines()[0]; node = "index scan in id order" if "Index Scan" in txt else "scan + sort" plans.append({"query": label, "plan": node, "ms": float(re.search(r"Execution Time: ([\d.]+)", txt).group(1))}) for r in methods: print(f"{r['method']:<28} {r['seconds']:6.2f} s extra peak memory {r['extra_peak_mb']:7.1f} MB") for r in plans: print(f"rare rows at the end, {r['query']:<14}: {r['plan']:<24} {r['ms']:9.1f} ms") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"methods": methods, "plans": plans}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
top-n-heap-against-full-sort.compose.yml # PostgreSQL for the top-n-heap-against-full-sort lab. services: postgres: image: postgres:17.6 command: ["postgres", "-c", "shared_buffers=256MB", "-c", "max_parallel_workers_per_gather=0"] environment: POSTGRES_PASSWORD: lab ports: ["55707:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 1s retries: 304Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.
Terminal docker compose -f top-n-heap-against-full-sort.compose.yml up -d --wait uv run top-n-heap-against-full-sort.py uv run top-n-heap-against-full-sort.py --help # every knob docker compose -f top-n-heap-against-full-sort.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55707/postgres | Connection string. |
| --n20,000,000 | Random numbers. |
| --rows5,000,000 | Rows in the PostgreSQL table. |
| --rare5000 | Rare rows, all at the end of the table. |
Our run
Top 100 of 20 million numbers
| Method | Time | Extra peak memory (MB) |
|---|---|---|
| sorted(data)[-100:] | 4.31 s | 530 |
| heapq.nlargest(100, data) | 180 ms | 0 |
Rare rows at the end of the table
| Query | Plan | Time (ms) |
|---|---|---|
| with LIMIT 10 | index scan in id order | 444 |
| without LIMIT | scan + sort | 177 |
sorted(data)[-100:] 4.31 s extra peak memory 530.1 MB
heapq.nlargest(100, data) 0.18 s extra peak memory 0.0 MB
rare rows at the end, with LIMIT 10 : index scan in id order 444.1 ms
rare rows at the end, without LIMIT : scan + sort 176.7 msWhat we found
The heap took 180 ms against 4.31 s for the sort, and needed no measurable extra memory, while the sort's copy added 530 MB. In PostgreSQL the LIMIT made the plan worse: with it, the planner walked the primary key in id order (index scan in id order), expecting to find ten rare rows early, and took 444 ms; without it, it scanned and sorted (scan + sort) in 177 ms.
Read it
A LIMIT is a bet that the matching rows are spread evenly in the order being walked. The planner's statistics give it the frequency of 'rare' but not its position, so a correlation between the filter and the sort order turns the cheap early exit into a walk through nearly the whole index. The fix is a plan that does not depend on position: an index on (flag, id), or statistics that capture the correlation.