Skip to content
About
Main content

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

On shuffled data, at what selectivity does the index scan start losing to the 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-index-versus-scan-crossover.py63 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    find-the-index-versus-scan-crossover.compose.yml13 linesDownload
    View 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: 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-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
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55703/postgres Connection string.
--rows5,000,000 Rows.

Our run

Time against selectivityApple M2 · 8 cores · 8 GB

clustered

shuffled

Data table
Time against selectivity
layoutSelectivity (%)seriesTime (ms)
clustered0.01index scan0.061
clustered0.01sequential scan121
clustered0.1index scan0.511
clustered0.1sequential scan122
clustered1index scan5.01
clustered1sequential scan129
clustered5index scan26.3
clustered5sequential scan143
clustered20index scan110
clustered20sequential scan199
clustered50index scan255
clustered50sequential scan355
shuffled0.01index scan0.193
shuffled0.01sequential scan204
shuffled0.1index scan2.22
shuffled0.1sequential scan209
shuffled1index scan25.4
shuffled1sequential scan216
shuffled5index scan250
shuffled5sequential scan221
shuffled20index scan967
shuffled20sequential scan272
shuffled50index scan2,700
shuffled50sequential scan390
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

The planner's own choice

The planner's own choice
LayoutSelectivity (%)random_page_costPlanner picks
clustered0.014index
clustered0.011.1index
clustered0.14index
clustered0.11.1index
clustered14index
clustered11.1index
clustered54index
clustered51.1index
clustered204index
clustered201.1index
clustered504index
clustered501.1index
shuffled0.014bitmap
shuffled0.011.1index
shuffled0.14bitmap
shuffled0.11.1bitmap
shuffled14bitmap
shuffled11.1bitmap
shuffled54bitmap
shuffled51.1bitmap
shuffled204bitmap
shuffled201.1bitmap
shuffled504bitmap
shuffled501.1index
Output · uv run find-the-index-versus-scan-crossover.py
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% -> index

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