Skip to content
About
Main content

Find the crossover on your hardware

Run a real systemNeeds Docker, Python

Problem

An index finds matching rows without reading the table, but each match costs a jump to wherever the row lives. When matching rows are scattered across the table, every match can be a separate page read, and past a small fraction of the table it is cheaper to read everything in order. When the table is physically ordered by the indexed column (clustered), matches sit together on few pages, and the index keeps winning far longer. The folklore threshold ("use the index below 10–20%") ignores both the layout and the storage.

Create a PostgreSQL table with 5 million rows and a column whose value is random in 1 to 1,000,000. Index it. Run EXPLAIN (ANALYZE) for WHERE v < X with X chosen to return 0.01%, 0.1%, 1%, 5% and 20% of the rows, with enable_seqscan on and off, and note the point where the scan becomes faster. Repeat with SET random_page_cost = 1.1. Then run CLUSTER t USING t_v; and repeat.

The question. At what fraction of the table does the index stop winning, scattered and clustered, and does random_page_cost move the ?


Predict

On scattered data, at what share of the table does the index stop beating a sequential scan?

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.

    find-the-crossover-on-your-hardware.py54 linesDownload
    View the code
    find-the-crossover-on-your-hardware.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
    """Find the index-versus-scan crossover on your hardware.
    
    A 5-million-row PostgreSQL table with a random value column and an index on
    it. For predicates returning 0.01% to 20% of the rows, the time of an index
    scan (sequential scan disabled) against a sequential scan (index disabled);
    with random_page_cost 4 and 1.1; then after CLUSTER on the index.
    Needs the PostgreSQL service from the compose file (port 55520).
    """
    import argparse, json, re
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55520/postgres", help="Connection string.")
    p.add_argument("--rows", type=int, default=5_000_000, help="Rows.")
    p.add_argument("--fractions", type=lambda s: [float(v) for v in s.split(",")], default=[0.0001, 0.001, 0.01, 0.05, 0.2], help="Shares of rows returned.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    con = psycopg.connect(args.dsn, autocommit=True)
    con.execute("DROP TABLE IF EXISTS t")
    con.execute(f"CREATE TABLE t AS SELECT g AS id, (random()*1000000)::int AS v, md5(g::text) AS pad FROM generate_series(1, {args.rows}) g")
    con.execute("CREATE INDEX t_v ON t (v)"); con.execute("ANALYZE t")
    
    def timed(sql, scan):
        con.execute("SET enable_seqscan = %s" % ("on" if scan == "seq" else "off"))
        con.execute("SET enable_indexscan = %s" % ("on" if scan == "index" else "off"))
        con.execute("SET enable_bitmapscan = %s" % ("on" if scan == "index" else "off"))
        plan = "\n".join(r[0] for r in con.execute("EXPLAIN (ANALYZE) " + sql))
        return float(re.search(r"Execution Time: ([\d.]+)", plan).group(1)), plan.splitlines()[0].split("  ")[0].strip()
    
    rows, crossings = [], []
    for layout in ("scattered", "clustered on the index"):
        if layout.startswith("clustered"): con.execute("CLUSTER t USING t_v"); con.execute("ANALYZE t")
        for rpc in (4, 1.1):
            con.execute(f"SET random_page_cost = {rpc}")
            for f in args.fractions:
                sql = f"SELECT * FROM t WHERE v < {int(f * 1_000_000)}"
                ti, _ = timed(sql, "index"); ts, _ = timed(sql, "seq")
                con.execute("RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan")
                chosen = "\n".join(r[0] for r in con.execute("EXPLAIN " + sql)).splitlines()[0].split("(")[0].strip()
                rows.append({"layout": layout, "random_page_cost": rpc, "pct": f * 100, "index_ms": ti, "seq_ms": ts, "planner_chose": chosen})
                print(f"{layout:<24} rpc {rpc:<4} {f:7.2%}: index {ti:9.1f} ms   seq scan {ts:9.1f} ms   planner: {chosen}")
            cross = next((r["pct"] for r in rows if r["layout"] == layout and r["random_page_cost"] == rpc and r["index_ms"] > r["seq_ms"]), None)
            crossings.append({"layout": layout, "random_page_cost": rpc, "index_stops_winning_pct": cross if cross is not None else -1})
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"timings": rows, "crossings": crossings}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    find-the-crossover-on-your-hardware.compose.yml13 linesDownload
    View the code
    find-the-crossover-on-your-hardware.compose.yml
    # PostgreSQL for this lab.
    services:
      postgres:
        image: postgres:17.6
        environment:
          POSTGRES_PASSWORD: lab
        command: ["postgres", "-c", "shared_buffers=256MB"]
        ports: ["55520: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 find-the-crossover-on-your-hardware.compose.yml up -d --wait
    uv run find-the-crossover-on-your-hardware.py
    uv run find-the-crossover-on-your-hardware.py --help     # every knob
    docker compose -f find-the-crossover-on-your-hardware.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55520/postgres Connection string.
--rows5,000,000 Rows.
--fractions0.0001, 0.001, 0.01, 0.05, 0.2 Shares of rows returned.

Our run

Index against sequential scan (random_page_cost 4)Apple M2 · 8 cores · 8 GB

scattered

clustered on the index

Data table
Index against sequential scan (random_page_cost 4)
layoutRows returned (%)IndexSequential scan
scattered0.011.6162.3
scattered0.11464.3
scattered19776
scattered517377.6
scattered20260202
scattered0.011.1661.6
scattered0.17.2261.6
scattered193.969.8
scattered516174.2
scattered20263225
clustered on the index0.010.07463.2
clustered on the index0.10.58360.6
clustered on the index16.1666.9
clustered on the index531.489.9
clustered on the index20117193
clustered on the index0.010.05559.9
clustered on the index0.10.44959.7
clustered on the index14.4185.7
clustered on the index522.885.5
clustered on the index2085.7191
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

Timings and the planner's choice

Timings and the planner's choice
Layoutrandom_page_costRows (%)Index (ms)Seq scan (ms)Planner chose
scattered40.011.6162.3Bitmap Heap Scan on t
scattered40.11464.3Bitmap Heap Scan on t
scattered419776Bitmap Heap Scan on t
scattered4517377.6Bitmap Heap Scan on t
scattered420260202Bitmap Heap Scan on t
scattered1.10.011.1661.6Index Scan using t_v on t
scattered1.10.17.2261.6Bitmap Heap Scan on t
scattered1.1193.969.8Bitmap Heap Scan on t
scattered1.1516174.2Bitmap Heap Scan on t
scattered1.120263225Bitmap Heap Scan on t
clustered on the index40.010.07463.2Index Scan using t_v on t
clustered on the index40.10.58360.6Index Scan using t_v on t
clustered on the index416.1666.9Index Scan using t_v on t
clustered on the index4531.489.9Index Scan using t_v on t
clustered on the index420117193Index Scan using t_v on t
clustered on the index1.10.010.05559.9Index Scan using t_v on t
clustered on the index1.10.10.44959.7Index Scan using t_v on t
clustered on the index1.114.4185.7Index Scan using t_v on t
clustered on the index1.1522.885.5Index Scan using t_v on t
clustered on the index1.12085.7191Index Scan using t_v on t
Output · uv run find-the-crossover-on-your-hardware.py
scattered                rpc 4      0.01%: index       1.6 ms   seq scan      62.3 ms   planner: Bitmap Heap Scan on t
scattered                rpc 4      0.10%: index      14.0 ms   seq scan      64.3 ms   planner: Bitmap Heap Scan on t
scattered                rpc 4      1.00%: index      97.0 ms   seq scan      76.0 ms   planner: Bitmap Heap Scan on t
scattered                rpc 4      5.00%: index     172.7 ms   seq scan      77.6 ms   planner: Bitmap Heap Scan on t
scattered                rpc 4     20.00%: index     260.4 ms   seq scan     202.3 ms   planner: Bitmap Heap Scan on t
scattered                rpc 1.1    0.01%: index       1.2 ms   seq scan      61.6 ms   planner: Index Scan using t_v on t
scattered                rpc 1.1    0.10%: index       7.2 ms   seq scan      61.6 ms   planner: Bitmap Heap Scan on t
scattered                rpc 1.1    1.00%: index      93.9 ms   seq scan      69.8 ms   planner: Bitmap Heap Scan on t
scattered                rpc 1.1    5.00%: index     161.2 ms   seq scan      74.2 ms   planner: Bitmap Heap Scan on t
scattered                rpc 1.1   20.00%: index     263.4 ms   seq scan     224.8 ms   planner: Bitmap Heap Scan on t
clustered on the index   rpc 4      0.01%: index       0.1 ms   seq scan      63.2 ms   planner: Index Scan using t_v on t
clustered on the index   rpc 4      0.10%: index       0.6 ms   seq scan      60.6 ms   planner: Index Scan using t_v on t
clustered on the index   rpc 4      1.00%: index       6.2 ms   seq scan      66.9 ms   planner: Index Scan using t_v on t
clustered on the index   rpc 4      5.00%: index      31.4 ms   seq scan      89.9 ms   planner: Index Scan using t_v on t
clustered on the index   rpc 4     20.00%: index     117.0 ms   seq scan     192.9 ms   planner: Index Scan using t_v on t
clustered on the index   rpc 1.1    0.01%: index       0.1 ms   seq scan      59.9 ms   planner: Index Scan using t_v on t
clustered on the index   rpc 1.1    0.10%: index       0.4 ms   seq scan      59.7 ms   planner: Index Scan using t_v on t
clustered on the index   rpc 1.1    1.00%: index       4.4 ms   seq scan      85.7 ms   planner: Index Scan using t_v on t
clustered on the index   rpc 1.1    5.00%: index      22.8 ms   seq scan      85.5 ms   planner: Index Scan using t_v on t
clustered on the index   rpc 1.1   20.00%: index      85.7 ms   seq scan     191.1 ms   planner: Index Scan using t_v on t

What we found

On scattered data the index stopped winning at 1% of the table: 97 ms against 76 ms for the scan. Setting random_page_cost = 1.1 did not move the measured crossover (1%), and the planner chose a bitmap scan at every but the smallest, at both settings. After CLUSTER the index won at every selectivity tested, up to 20% (117 ms against 193 ms).


Read it

The crossover is about 1% on scattered data here, an order of magnitude below the folklore number, and above 20% on clustered data. Both come from the same mechanism: the cost of an index scan is the number of distinct pages it touches, which depends on how the matching rows are laid out, not on how many there are.

random_page_cost changes the planner's estimate, not the hardware. On this machine the data was cached, so the measured crossover did not move; on real storage a lower setting tells the planner what an SSD's random read costs.