Find the index-versus-scan crossover on your machine
Run a real systemNeeds Docker, Python
Problem
An index scan reads only the rows it needs, but reaches each through a random page access; a sequential scan reads everything, in order. Which is faster depends on the fraction of rows selected and on how the table is laid out: if rows with nearby keys sit on the same pages (the table is clustered on the key), an index scan reads pages almost sequentially; if they are scattered, each row can cost its own page read. The planner models this with random_page_cost and the index's correlation statistic.
Create a table of 5 million rows with an indexed column k that has a known selectivity per value. For selectivities from 0.01% to 50%, force each method and time it with EXPLAIN ANALYZE. Do it once with data in physical order of k (clustered) and once shuffled. Also record the planner's own choice at random_page_cost 4 (the default) and 1.1.
The question. At what selectivity does the index scan start losing to the sequential scan, clustered and shuffled, and does the planner choose accordingly?
Predict
Expect the lines to cross at some single-digit percentage.
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-index-versus-scan-crossover.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. A PostgreSQL table of 5 million rows with an indexed column k whose values select 0.01% to 50% of the rows. For each selectivity, forces an index scan and then a sequential scan and times both (EXPLAIN ANALYZE), with the data in physical order of k (clustered) and shuffled. Also records which plan the planner picks on its own, at the default random_page_cost (4) and at 1.1. Needs the PostgreSQL service from the compose file (port 55703). """ import argparse, json, re import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55703/postgres", help="Connection string.") p.add_argument("--rows", type=int, default=5_000_000, help="Rows.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() SELECTIVITY = [0.0001, 0.001, 0.01, 0.05, 0.2, 0.5] c = psycopg.connect(args.dsn, autocommit=True) def ms(sql): plan = "\n".join(r[0] for r in c.execute("EXPLAIN (ANALYZE, BUFFERS) " + sql).fetchall()) return float(re.search(r"Execution Time: ([\d.]+)", plan).group(1)), plan def k_where(sel): # k is a percentile bucket: k < sel * 10,000 selects sel of the rows return f"k < {int(sel * 10000)}" rows, choices = [], [] for layout, order in (("clustered", "g"), ("shuffled", "random()")): c.execute("DROP TABLE IF EXISTS t") c.execute(f"CREATE TABLE t AS SELECT (g * 10000::bigint / {args.rows})::int AS k, md5(g::text) AS pad FROM generate_series(0, {args.rows - 1}) g ORDER BY {order}") c.execute("CREATE INDEX ON t (k)"); c.execute("VACUUM ANALYZE t") for sel in SELECTIVITY: q = f"SELECT count(pad) FROM t WHERE {k_where(sel)}" for setting in ("index", "seq"): c.execute("RESET ALL") if setting == "index": c.execute("SET enable_seqscan = off"); c.execute("SET enable_bitmapscan = off") else: c.execute("SET enable_indexscan = off"); c.execute("SET enable_bitmapscan = off") ms(q); t, _ = ms(q) # warm, then measure rows.append({"layout": layout, "selectivity_pct": sel * 100, "method": "index scan" if setting == "index" else "sequential scan", "ms": t}) for rpc in (4, 1.1): c.execute("RESET ALL"); c.execute(f"SET random_page_cost = {rpc}") plan = "\n".join(r[0] for r in c.execute("EXPLAIN " + q).fetchall()) pick = "bitmap" if "Bitmap" in plan else "index" if "Index" in plan else "seq" choices.append({"layout": layout, "selectivity_pct": sel * 100, "random_page_cost": rpc, "planner_pick": pick}) cross = [] for layout in ("clustered", "shuffled"): pts = [(r["selectivity_pct"], r["ms"]) for r in rows if r["layout"] == layout and r["method"] == "index scan"] seq = {r["selectivity_pct"]: r["ms"] for r in rows if r["layout"] == layout and r["method"] == "sequential scan"} first = next((s for s, t in pts if t > seq[s]), None) cross.append({"layout": layout, "index_loses_from_pct": first}) for r in rows: print(f"{r['layout']:<9} {r['selectivity_pct']:>6g}% {r['method']:<15} {r['ms']:9.1f} ms") for r in cross: print(f"{r['layout']}: index scan slower than the sequential scan from {r['index_loses_from_pct']}% selectivity") for r in choices: print(f" planner at random_page_cost {r['random_page_cost']:<4}: {r['layout']:<9} {r['selectivity_pct']:>6g}% -> {r['planner_pick']}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"times": rows, "crossover": cross, "planner": choices}}, 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-index-versus-scan-crossover.compose.yml # PostgreSQL for the find-the-index-versus-scan-crossover 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: ["55703: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-index-versus-scan-crossover.compose.yml up -d --wait uv run find-the-index-versus-scan-crossover.py uv run find-the-index-versus-scan-crossover.py --help # every knob docker compose -f find-the-index-versus-scan-crossover.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55703/postgres | Connection string. |
| --rows5,000,000 | Rows. |
Our run
clustered
shuffled
Data table
| layout | Selectivity (%) | series | Time (ms) |
|---|---|---|---|
| clustered | 0.01 | index scan | 0.061 |
| clustered | 0.01 | sequential scan | 121 |
| clustered | 0.1 | index scan | 0.511 |
| clustered | 0.1 | sequential scan | 122 |
| clustered | 1 | index scan | 5.01 |
| clustered | 1 | sequential scan | 129 |
| clustered | 5 | index scan | 26.3 |
| clustered | 5 | sequential scan | 143 |
| clustered | 20 | index scan | 110 |
| clustered | 20 | sequential scan | 199 |
| clustered | 50 | index scan | 255 |
| clustered | 50 | sequential scan | 355 |
| shuffled | 0.01 | index scan | 0.193 |
| shuffled | 0.01 | sequential scan | 204 |
| shuffled | 0.1 | index scan | 2.22 |
| shuffled | 0.1 | sequential scan | 209 |
| shuffled | 1 | index scan | 25.4 |
| shuffled | 1 | sequential scan | 216 |
| shuffled | 5 | index scan | 250 |
| shuffled | 5 | sequential scan | 221 |
| shuffled | 20 | index scan | 967 |
| shuffled | 20 | sequential scan | 272 |
| shuffled | 50 | index scan | 2,700 |
| shuffled | 50 | sequential scan | 390 |
The planner's own choice
| Layout | Selectivity (%) | random_page_cost | Planner picks |
|---|---|---|---|
| clustered | 0.01 | 4 | index |
| clustered | 0.01 | 1.1 | index |
| clustered | 0.1 | 4 | index |
| clustered | 0.1 | 1.1 | index |
| clustered | 1 | 4 | index |
| clustered | 1 | 1.1 | index |
| clustered | 5 | 4 | index |
| clustered | 5 | 1.1 | index |
| clustered | 20 | 4 | index |
| clustered | 20 | 1.1 | index |
| clustered | 50 | 4 | index |
| clustered | 50 | 1.1 | index |
| shuffled | 0.01 | 4 | bitmap |
| shuffled | 0.01 | 1.1 | index |
| shuffled | 0.1 | 4 | bitmap |
| shuffled | 0.1 | 1.1 | bitmap |
| shuffled | 1 | 4 | bitmap |
| shuffled | 1 | 1.1 | bitmap |
| shuffled | 5 | 4 | bitmap |
| shuffled | 5 | 1.1 | bitmap |
| shuffled | 20 | 4 | bitmap |
| shuffled | 20 | 1.1 | bitmap |
| shuffled | 50 | 4 | bitmap |
| shuffled | 50 | 1.1 | index |
clustered 0.01% index scan 0.1 ms
clustered 0.01% sequential scan 121.0 ms
clustered 0.1% index scan 0.5 ms
clustered 0.1% sequential scan 122.0 ms
clustered 1% index scan 5.0 ms
clustered 1% sequential scan 129.0 ms
clustered 5% index scan 26.3 ms
clustered 5% sequential scan 142.6 ms
clustered 20% index scan 110.3 ms
clustered 20% sequential scan 199.3 ms
clustered 50% index scan 254.8 ms
clustered 50% sequential scan 355.1 ms
shuffled 0.01% index scan 0.2 ms
shuffled 0.01% sequential scan 203.6 ms
shuffled 0.1% index scan 2.2 ms
shuffled 0.1% sequential scan 209.5 ms
shuffled 1% index scan 25.4 ms
shuffled 1% sequential scan 216.0 ms
shuffled 5% index scan 250.4 ms
shuffled 5% sequential scan 221.3 ms
shuffled 20% index scan 966.8 ms
shuffled 20% sequential scan 272.4 ms
shuffled 50% index scan 2701.7 ms
shuffled 50% sequential scan 390.1 ms
clustered: index scan slower than the sequential scan from None% selectivity
shuffled: index scan slower than the sequential scan from 5.0% selectivity
planner at random_page_cost 4 : clustered 0.01% -> index
planner at random_page_cost 1.1 : clustered 0.01% -> index
planner at random_page_cost 4 : clustered 0.1% -> index
planner at random_page_cost 1.1 : clustered 0.1% -> index
planner at random_page_cost 4 : clustered 1% -> index
planner at random_page_cost 1.1 : clustered 1% -> index
planner at random_page_cost 4 : clustered 5% -> index
planner at random_page_cost 1.1 : clustered 5% -> index
planner at random_page_cost 4 : clustered 20% -> index
planner at random_page_cost 1.1 : clustered 20% -> index
planner at random_page_cost 4 : clustered 50% -> index
planner at random_page_cost 1.1 : clustered 50% -> index
planner at random_page_cost 4 : shuffled 0.01% -> bitmap
planner at random_page_cost 1.1 : shuffled 0.01% -> index
planner at random_page_cost 4 : shuffled 0.1% -> bitmap
planner at random_page_cost 1.1 : shuffled 0.1% -> bitmap
planner at random_page_cost 4 : shuffled 1% -> bitmap
planner at random_page_cost 1.1 : shuffled 1% -> bitmap
planner at random_page_cost 4 : shuffled 5% -> bitmap
planner at random_page_cost 1.1 : shuffled 5% -> bitmap
planner at random_page_cost 4 : shuffled 20% -> bitmap
planner at random_page_cost 1.1 : shuffled 20% -> bitmap
planner at random_page_cost 4 : shuffled 50% -> bitmap
planner at random_page_cost 1.1 : shuffled 50% -> indexWhat we found
On shuffled data the index scan lost from 5% selectivity, a single-digit crossover as expected; at 50% it took 2,700 ms against 390 ms for the sequential scan. On clustered data it never lost in the range tested: at 50% it took 255 ms against 355 ms, because consecutive index entries point into the same pages. The planner never chose the sequential scan. On clustered data it picked the index at every selectivity, correctly; on shuffled data it picked bitmap scans (which sort row positions before reading pages, a middle way this lab did not time), and at 50% with random_page_cost = 1.1 a plain index scan, the slowest option measured at that point.
Read it
There is no single crossover selectivity: it is a property of the layout as much as of the query. On a table whose physical order matches the key, an index stays useful for large fractions; on a scattered one it stops paying off within a few percent. The planner knows this through the correlation statistic, and random_page_cost tells it how expensive a random read is on your storage, which on SSDs is far less than the default assumes.