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 planner?
Predict
For scattered values the index stops winning at a small fraction of the table (low single digits of percent or less, not the folklore number), and lowering random_page_cost moves the switch point later. After CLUSTER the crossover moves far up, as the clustered formula predicts.
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
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)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide 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: 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 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
| Knob · default | What 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
scattered
clustered on the index
Data table
| layout | Rows returned (%) | Index | Sequential scan |
|---|---|---|---|
| scattered | 0.01 | 1.61 | 62.3 |
| scattered | 0.1 | 14 | 64.3 |
| scattered | 1 | 97 | 76 |
| scattered | 5 | 173 | 77.6 |
| scattered | 20 | 260 | 202 |
| scattered | 0.01 | 1.16 | 61.6 |
| scattered | 0.1 | 7.22 | 61.6 |
| scattered | 1 | 93.9 | 69.8 |
| scattered | 5 | 161 | 74.2 |
| scattered | 20 | 263 | 225 |
| clustered on the index | 0.01 | 0.074 | 63.2 |
| clustered on the index | 0.1 | 0.583 | 60.6 |
| clustered on the index | 1 | 6.16 | 66.9 |
| clustered on the index | 5 | 31.4 | 89.9 |
| clustered on the index | 20 | 117 | 193 |
| clustered on the index | 0.01 | 0.055 | 59.9 |
| clustered on the index | 0.1 | 0.449 | 59.7 |
| clustered on the index | 1 | 4.41 | 85.7 |
| clustered on the index | 5 | 22.8 | 85.5 |
| clustered on the index | 20 | 85.7 | 191 |
Timings and the planner's choice
| Layout | random_page_cost | Rows (%) | Index (ms) | Seq scan (ms) | Planner chose |
|---|---|---|---|---|---|
| scattered | 4 | 0.01 | 1.61 | 62.3 | Bitmap Heap Scan on t |
| scattered | 4 | 0.1 | 14 | 64.3 | Bitmap Heap Scan on t |
| scattered | 4 | 1 | 97 | 76 | Bitmap Heap Scan on t |
| scattered | 4 | 5 | 173 | 77.6 | Bitmap Heap Scan on t |
| scattered | 4 | 20 | 260 | 202 | Bitmap Heap Scan on t |
| scattered | 1.1 | 0.01 | 1.16 | 61.6 | Index Scan using t_v on t |
| scattered | 1.1 | 0.1 | 7.22 | 61.6 | Bitmap Heap Scan on t |
| scattered | 1.1 | 1 | 93.9 | 69.8 | Bitmap Heap Scan on t |
| scattered | 1.1 | 5 | 161 | 74.2 | Bitmap Heap Scan on t |
| scattered | 1.1 | 20 | 263 | 225 | Bitmap Heap Scan on t |
| clustered on the index | 4 | 0.01 | 0.074 | 63.2 | Index Scan using t_v on t |
| clustered on the index | 4 | 0.1 | 0.583 | 60.6 | Index Scan using t_v on t |
| clustered on the index | 4 | 1 | 6.16 | 66.9 | Index Scan using t_v on t |
| clustered on the index | 4 | 5 | 31.4 | 89.9 | Index Scan using t_v on t |
| clustered on the index | 4 | 20 | 117 | 193 | Index Scan using t_v on t |
| clustered on the index | 1.1 | 0.01 | 0.055 | 59.9 | Index Scan using t_v on t |
| clustered on the index | 1.1 | 0.1 | 0.449 | 59.7 | Index Scan using t_v on t |
| clustered on the index | 1.1 | 1 | 4.41 | 85.7 | Index Scan using t_v on t |
| clustered on the index | 1.1 | 5 | 22.8 | 85.5 | Index Scan using t_v on t |
| clustered on the index | 1.1 | 20 | 85.7 | 191 | Index Scan using t_v on t |
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 tWhat we found
On scattered data the index stopped winning at 1% of the table: 97 ms against 76 ms for the sequential scan. Setting random_page_cost = 1.1 did not move the measured crossover (1%), and the planner chose a bitmap scan at every selectivity 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.