PostgreSQL 18 Skip Scan: Multicolumn Index Tuning Guide
Every Postgres team inherits the same rule: a multicolumn B-tree index is only useful when your query constrains the leading column. An index on (status, customer_id, shipped_at) did nothing for a query filtering on customer_id alone. The planner fell back to a sequential scan, and the reflex followed: build a second copy of the index with the columns in a different order, then a third. Storage grows, every write pays for more index maintenance, autovacuum has more work, and the index list becomes archaeology.
PostgreSQL 18 relaxed that rule. Its B-tree skip scan lets a multicolumn index serve queries that omit the leading column — within limits that matter a great deal in production. If you run Postgres 18 and you or your team has ever answered a slow report by adding a redundant index, this is the feature to understand this quarter.
The Leftmost-Prefix Rule, and What Skip Scan Changes
A B-tree on (status, customer_id, shipped_at) sorts entries by status first, then customer_id within each status, then shipped_at. A query with status = 'delayed' AND customer_id = 8842 reads one contiguous slice — cheap. A query with only customer_id = 8842 has matches scattered across every status group, so before version 18 the planner usually ignored the index.
The 18 release notes state it plainly: skip scan “allows multi-column btree indexes to be used in more cases such as when there are no restrictions on the first or early indexed columns (or there are non-equality ones), and there are useful restrictions on later indexed columns” (PostgreSQL 18 release notes). The planner enumerates the distinct values of the skipped leading column and performs a small index seek per value, then advances. Conceptually it rewrites your query as a union of one targeted range scan per leading value:
-- index on (status, customer_id, shipped_at)
SELECT * FROM shipments
WHERE customer_id = 8842
AND shipped_at >= now() - interval '30 days';
-- what the planner effectively executes under skip scan
SELECT ... WHERE status = 'pending' AND customer_id = 8842 AND shipped_at >= ...
UNION ALL
SELECT ... WHERE status = 'in_transit' AND customer_id = 8842 AND shipped_at >= ...
UNION ALL
SELECT ... WHERE status = 'delayed' AND customer_id = 8842 AND shipped_at >= ...;
Skip scan is a B-tree optimization only — not GIN, GiST, BRIN or hash. And it requires an equality condition on a later column of the index. Range predicates alone on later columns are not the target case.
Reading the Plan: the “Index Searches” Counter
Postgres 18 also made skip scan measurable. Index Scan, Bitmap Index Scan and Index-Only Scan nodes now report an Index Searches line — the total number of index probes across all loops. A single contiguous range scan reports 1. A skip scan reports one search per distinct leading value, so that number is your diagnostic signal:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, shipped_at FROM shipments
WHERE customer_id = 8842 AND shipped_at >= now() - interval '30 days';
Index Scan using shipments_status_customer_shipped_idx on shipments
(cost=0.43..142.90 rows=41 width=16) (actual time=0.031..0.184 rows=37 loops=1)
Index Cond: ((customer_id = 8842) AND (shipped_at >= ...))
Index Searches: 5
Buffers: shared hit=23 read=2
Those figures are illustrative, but the shape is what to look for: Index Searches > 1 with a small buffer count means the planner skipped over leading-column groups instead of scanning the table. Five status values, five searches. If your read count instead tracks the heap size, you are looking at a plan that abandoned the index.
When Skip Scan Wins — and When It Backfires
Skip scan’s cost is proportional to the number of distinct values in the skipped prefix. That single fact decides whether it helps you:
- Low-cardinality prefix (the win). Status codes, region, feeder zone, tenant tier, document state — a handful to a few dozen values. Reporting and BI queries that filter by an inner column get index access without a dedicated index.
- High-cardinality prefix (the trap). Flip the earlier example to
(customer_id, status, shipped_at)and query onstatusalone. The skipped column iscustomer_idwith millions of distinct values: “one search per distinct value” becomes millions of probes, worse than reading the table. Skip scan raises the floor; it does not raise the ceiling. Column order still matters. - Large result sets. If a query legitimately returns a big fraction of the table, a bitmap or sequential scan remains the correct plan.
The practical consequence: skip scan is a reason to delete duplicate indexes, not a reason to stop designing them. It also shifts capacity math slightly — plans that now use an index do more seeks and fewer sequential reads, which is exactly the workload the async I/O subsystem in Postgres 18 was built to overlap.
A Tuning Workflow You Can Run This Week
- Confirm the version. Skip scan exists from 18.0 onward; on earlier majors the leftmost-prefix rule is unchanged.
SELECT current_setting('server_version') AS v, current_setting('server_version_num')::int >= 180000 AS skip_scan_available; - Find the statements worth fixing. Sort pg_stat_statements by total time, not mean time — a 40 ms report called 200,000 times a day outranks a 9-second nightly job.
SELECT calls, round(total_exec_time::numeric, 1) AS total_ms, round(mean_exec_time::numeric, 2) AS mean_ms, rows, left(query, 90) AS query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20; - Capture plans with buffers. Use
EXPLAIN (ANALYZE, BUFFERS)in staging, or auto_explain withlog_analyze = onand a duration threshold where you cannot reproduce a production plan. - Classify the plan. A Seq Scan on a large table plus a matching multicolumn index is the skip-scan candidate. Check
Index Searches, then compareBuffers: shared hit/readagainst the table’s page count. - Choose one fix: reorder the index so the equality column leads; add a dedicated index only if the skipped prefix really is high cardinality; or leave the plan alone because the result set is genuinely large.
- Delete the redundant index. If skip scan now serves the pattern, drop the duplicate ordering — you recover write throughput and vacuum time immediately. Confirm with
pg_stat_user_indexes.idx_scanbefore dropping. - Re-measure after the change. Reset statistics or compare a fresh window; a plan change you did not measure is a guess.
Why This Matters for Houston Workloads
The pattern shows up constantly in local stacks. A drayage or 3PL dispatch app indexed on (terminal, customer_id, pickup_at) still gets hammered by customer-portal queries filtering only on customer_id. An energy or pipeline telemetry table indexed (feeder_zone, meter_id, read_at) gets queried by meter_id during outage triage. Multi-tenant SaaS here — logistics, energy services, healthcare scheduling — builds indexes with tenant_id leading, then wonders why a tenant-blind report is slow. Before 18, each of those meant another index. Now the question becomes: is the skipped prefix low cardinality? If yes, one index is enough and the duplicate can go.
Skip scan is not a plan-shape cure-all, and it does not remove the need to read EXPLAIN output. But it is one of the few version upgrades that lets you remove infrastructure rather than add it — and that is worth a few minutes of plan reading this week.
