Skip to content
About
Main content

Composite order and the range column

Run a real systemNeeds Docker, Python

Problem

A composite B-tree index is sorted by its first column, then by the second within it, and so on. An equality on a leading column narrows the scan to one contiguous range; a range condition on a column ends that: entries after it are no longer ordered by the following columns, so conditions on them must be checked entry by entry. The rule is to put equality columns first and the range column last.

Create t(a int, b int, c int) with 5 million random rows. Build (a, b, c) and (a, c, b) and compare EXPLAIN (ANALYZE) for a = 1 AND b > 5 AND c = 3. Look at "Index Cond" against "Filter" and at the number of index entries and buffers read.

The question. With each index, which conditions narrow the scan and how much does it read?


Predict

With index (a, b, c) and WHERE a = 1 AND b > 5 AND c = 3, how does PostgreSQL apply c = 3?

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.

    composite-order-and-the-range-column.py44 linesDownload
    View the code
    composite-order-and-the-range-column.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
    """Composite index order and the range column.
    
    5 million random rows t(a, b, c) with indexes (a, b, c) and (a, c, b), each
    tried alone for a = 1 AND b > 5 AND c = 3: what goes into Index Cond and what
    is left to Filter, the buffers read, and the time.
    Needs the PostgreSQL service from the compose file (port 55522).
    """
    import argparse, json, re
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55522/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()
    con = psycopg.connect(args.dsn, autocommit=True)
    con.execute("DROP TABLE IF EXISTS t")
    con.execute(f"CREATE TABLE t AS SELECT (random()*100)::int AS a, (random()*100)::int AS b, (random()*100)::int AS c FROM generate_series(1, {args.rows})")
    SQL = "SELECT * FROM t WHERE a = 1 AND b > 5 AND c = 3"
    rows = []
    for cols in ("a, b, c", "a, c, b"):
        con.execute("DROP INDEX IF EXISTS ix"); con.execute(f"CREATE INDEX ix ON t ({cols})"); con.execute("ANALYZE t")
        con.execute("SET enable_seqscan = off; SET enable_bitmapscan = off")
        plan = "\n".join(r[0] for r in con.execute("EXPLAIN (ANALYZE, BUFFERS) " + SQL))
        cond = re.search(r"Index Cond: \((.*)\)", plan); filt = re.search(r"Filter: \((.*)\)", plan)
        removed = re.search(r"Rows Removed by Filter: (\d+)", plan); bufs = re.search(r"Buffers: shared hit=(\d+)(?: read=(\d+))?", plan)
        rows.append({"index": f"({cols})", "index_cond": cond.group(1) if cond else "", "filter": filt.group(1) if filt else "(none)",
                     "rows_removed_by_filter": int(removed.group(1)) if removed else 0,
                     "buffers": int(bufs.group(1)) + int(bufs.group(2) or 0) if bufs else 0,
                     "ms": float(re.search(r"Execution Time: ([\d.]+)", plan).group(1))})
        r = rows[-1]
        print(f"index ({cols}): Index Cond [{r['index_cond']}]  Filter [{r['filter']}]  removed by filter {r['rows_removed_by_filter']:,}  "
              f"buffers {r['buffers']:,}  {r['ms']:.1f} ms")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"indexes": rows}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    composite-order-and-the-range-column.compose.yml13 linesDownload
    View the code
    composite-order-and-the-range-column.compose.yml
    # PostgreSQL for this lab.
    services:
      postgres:
        image: postgres:17.6
        environment:
          POSTGRES_PASSWORD: lab
        command: ["postgres", "-c", "shared_buffers=256MB"]
        ports: ["55522: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 composite-order-and-the-range-column.compose.yml up -d --wait
    uv run composite-order-and-the-range-column.py
    uv run composite-order-and-the-range-column.py --help     # every knob
    docker compose -f composite-order-and-the-range-column.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55522/postgres Connection string.
--rows5,000,000 Rows.

Our run

Two column orders

Two column orders
IndexIndex CondFilterBuffersTime (ms)
(a, b, c)(a = 1) AND (b > 5) AND (c = 3)(none)5351.68
(a, c, b)(a = 1) AND (c = 3) AND (b > 5)(none)4570.236
Reference runApple M2 · 8 cores · 8 GB
Output · uv run composite-order-and-the-range-column.py
index (a, b, c): Index Cond [(a = 1) AND (b > 5) AND (c = 3)]  Filter [(none)]  removed by filter 0  buffers 535  1.7 ms
index (a, c, b): Index Cond [(a = 1) AND (c = 3) AND (b > 5)]  Filter [(none)]  removed by filter 0  buffers 457  0.2 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

PostgreSQL listed all three conditions as the index condition with both indexes and no separate filter: with (a, b, c) it checks c = 3 inside the index scan, on each entry of the b > 5 range, rather than as a filter on table rows. The cost of the column order showed in the work instead: 535 buffers and 1.68 ms with (a, b, c), against 457 buffers and 0.236 ms with (a, c, b), where the range on b is the last column and the scan reads only matching entries.


Read it

The plan text hides the difference: "Index Cond" lists the condition whether it bounds the scan or is checked entry by entry. Buffers and time show it. Put equality columns before the range column, and read the buffer counts, not the condition list, to check.