Performance and tuning
David Sterling  

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 on status alone. The skipped column is customer_id with 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

  1. 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;
  2. 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;
  3. Capture plans with buffers. Use EXPLAIN (ANALYZE, BUFFERS) in staging, or auto_explain with log_analyze = on and a duration threshold where you cannot reproduce a production plan.
  4. Classify the plan. A Seq Scan on a large table plus a matching multicolumn index is the skip-scan candidate. Check Index Searches, then compare Buffers: shared hit/read against the table’s page count.
  5. 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.
  6. 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_scan before dropping.
  7. 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.

Leave A Comment