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 (a, c, b) all three conditions appear in the index condition and fewer entries are read; with (a, b, c) the condition on c is a filter on every entry scanned.
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
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)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide 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: 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 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
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55522/postgres | Connection string. |
| --rows5,000,000 | Rows. |
Our run
Two column orders
| Index | Index Cond | Filter | Buffers | Time (ms) |
|---|---|---|---|---|
| (a, b, c) | (a = 1) AND (b > 5) AND (c = 3) | (none) | 535 | 1.68 |
| (a, c, b) | (a = 1) AND (c = 3) AND (b > 5) | (none) | 457 | 0.236 |
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 msWhat 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.