Skip to content
About
Main content

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

In PostgreSQL, with the rare rows all at the end, what does adding LIMIT 10 do to the query time?

Run it

  1. 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 | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    top-n-heap-against-full-sort.py60 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    top-n-heap-against-full-sort.compose.yml13 linesDownload
    View 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: 30
    
  4. 4Start 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
Knobs
Knob · defaultWhat 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

Top 100 of 20 million numbers
MethodTimeExtra peak memory (MB)
sorted(data)[-100:]4.31 s530
heapq.nlargest(100, data)180 ms0

Rare rows at the end of the table

Rare rows at the end of the table
QueryPlanTime (ms)
with LIMIT 10index scan in id order444
without LIMITscan + sort177
Reference runApple M2 · 8 cores · 8 GB
Output · uv run top-n-heap-against-full-sort.py
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 ms
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

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