Index Correlation and Physical Row Order #
Two range queries return the same 200,000 rows from the same 80-million-row table through B-tree indexes of the same size. One reads 1,900 heap pages and finishes in 90 ms. The other reads 186,000 heap pages and takes 11 seconds, and the planner refuses to use its index unless forced. The difference is not the index; it is where the matching rows sit on disk.
The general crossover between access paths is covered in sequential vs index scans. This page covers the statistic that moves that crossover more than any other for range scans: correlation.
The Cost-Model Condition #
An index scan reads index entries in key order and fetches each matching row from the heap. If rows with neighbouring key values live on neighbouring pages, consecutive fetches hit the same page or the next one — nearly sequential I/O. If they are scattered, nearly every fetch is a new random page.
ANALYZE measures this as pg_stats.correlation: the correlation between the column’s sort order and the physical row order, from −1 to 1. The planner’s index scan cost interpolates between two extremes using the square of the correlation:
- Best case (
correlation² = 1): pages fetched ≈ fraction of the table selected, costed mostly atseq_page_cost. - Worst case (
correlation² = 0): one random page per row, capped at the table size, costed atrandom_page_cost.
Because the correlation is squared, 0.7 counts as barely half of perfect ordering, and 0.3 is almost indistinguishable from random. Typical values:
created_aton an append-only table — 0.99+; rows arrive in order.idfrom a sequence — 0.99+, until heavy updates move row versions.customer_id,email, UUID keys — near 0; insert order is unrelated to value order.
Bitmap heap scans sidestep correlation by sorting page fetches physically, which is why the planner often prefers them for low-correlation columns — see bitmap heap scan vs index scan thresholds.
Annotated EXPLAIN Evidence #
SELECT attname, correlation FROM pg_stats
WHERE tablename = 'payments' AND attname IN ('created_at', 'merchant_id');
attname | correlation
-------------+-------------
created_at | 0.998812
merchant_id | 0.004120
High correlation:
Index Scan using payments_created_at_idx on payments
(cost=0.57..9204.10 rows=201400 width=64) (actual time=0.03..88.4 rows=204110 loops=1)
Index Cond: (created_at >= '2026-09-10' AND created_at < '2026-09-11')
Buffers: shared hit=2440 read=1904
-- ~4,300 buffers for 204k rows → ~50 rows per page touched
Low correlation, index scan forced for comparison:
Index Scan using payments_merchant_id_idx on payments
(cost=0.57..648210.30 rows=198200 width=64) (actual time=0.05..11204.6 rows=199840 loops=1)
Index Cond: (merchant_id = ANY ('{…}'::bigint[]))
Buffers: shared hit=14002 read=186220
-- ~200k buffers for 200k rows → one page per row: fully random heap access
-- cost is 70× the created_at scan for the same row count
The metric to read: heap buffers per row returned. Near 1 / rows-per-page means ordered access; near 1.0 means random.
Step-by-Step Resolution #
-
Read
correlationfor the column driving the range orORDER BYscan. -
Measure buffers per row with
EXPLAIN (ANALYZE, BUFFERS)on the real query. Methods for attributing buffers to nodes are in using BUFFERS to find I/O hotspots. -
Choose the remedy by access pattern.
- Many rows by a scattered key, no ordering needed: let the planner use a bitmap heap scan; do not force an index scan.
- The query also needs heap columns and rows per key are many: a covering index removes heap fetches entirely — see covering index design.
- The table is read mostly by one key and rarely written:
CLUSTERreorders it once. - The column is naturally ordered by insertion and range-scanned: a BRIN index is far smaller than a B-tree.
-
Apply it.
-- one-off physical reorder (takes ACCESS EXCLUSIVE lock for the rewrite) CLUSTER payments USING payments_merchant_id_idx; ANALYZE payments; -- or, for the naturally ordered column CREATE INDEX payments_created_at_brin ON payments USING brin (created_at); -
Re-check correlation and buffers per row. After
CLUSTER,correlationfor the clustering column should be near 1, and heap reads should collapse toward sequential.
Before and After #
-- BEFORE: merchant lookup, correlation 0.004
Index Scan using payments_merchant_id_idx Buffers: shared read=186220 Execution Time: 11204.6 ms
-- AFTER: CLUSTER payments USING payments_merchant_id_idx; ANALYZE (correlation 0.9997)
Index Scan using payments_merchant_id_idx Buffers: shared read=3108 Execution Time: 142.9 ms
How correlation decays and how to watch it #
CLUSTER produces perfect order once. After that, every INSERT goes to whatever page has free space — often the end of the table, sometimes a hole left by a deleted row — and every UPDATE writes a new row version, which stays on the same page only when there is room there (a HOT update) and otherwise moves elsewhere. On a table where most activity is inserts of new merchants’ payments, correlation on merchant_id falls steadily as new rows accumulate outside the clustered region. On a table with frequent updates to wide rows, it falls faster still.
Leaving free space on each page with fillfactor slows the decay for updates, because more updates stay on their original page:
ALTER TABLE payments SET (fillfactor = 90);
CLUSTER payments USING payments_merchant_id_idx; -- rewrite applies the fillfactor
Track the statistic after each automatic analyze, and schedule the next CLUSTER when it drops below the point where the plan changed — for this workload, roughly 0.9:
SELECT now(), correlation FROM pg_stats
WHERE tablename = 'payments' AND attname = 'merchant_id';
Correlation and multicolumn or expression indexes #
pg_stats.correlation is stored per column, and for a multicolumn index the planner uses the correlation of the leading column only. An index on (merchant_id, created_at) is costed as randomly ordered if merchant_id is, even when rows for each merchant happen to be stored in time order. Expression indexes use the expression’s statistics, which exist only after an ANALYZE that follows the index creation. In both cases the estimate may be more pessimistic than reality, and the evidence to trust is the measured buffers per row in EXPLAIN (ANALYZE, BUFFERS), not the cost.
Common Pitfalls #
Clustering a write-heavy table. New and updated rows land wherever there is space, so correlation decays quickly. Diagnostic signal: correlation falling back toward zero within days. Fix: prefer a covering index or bitmap scans; CLUSTER suits mostly-static tables.
Running CLUSTER on a live table. It holds an ACCESS EXCLUSIVE lock for the whole rewrite. Diagnostic signal: blocked reads and writes for the duration. Fix: schedule a maintenance window, or use an online table-rewrite tool.
Creating BRIN on an unordered column. Each block range then spans nearly the whole value range, so every range matches. Diagnostic signal: Rows Removed by Index Recheck close to the table size. Fix: use BRIN only where correlation is naturally high.
Forgetting to re-analyze. The planner keeps using the old correlation until statistics are refreshed. Diagnostic signal: no plan change after CLUSTER. Fix: ANALYZE immediately after.
Frequently Asked Questions #
What does correlation in pg_stats mean? The correlation between a column’s value order and the table’s physical row order, from −1 to 1. The planner uses it to estimate how many random page reads an index scan needs.
Does CLUSTER keep the table ordered? No. It rewrites the table once. New and updated rows go wherever space is free, so correlation decays and the command must be repeated.
When is a BRIN index a better choice than a B-tree? When the column is naturally and persistently correlated with physical order, such as an insert timestamp on an append-only table.
Related #
- Sequential vs Index Scans — parent guide: how the access path is chosen
- Why Index Scans Sometimes Underperform — sibling: other reasons an index loses to a scan
- How PostgreSQL Calculates Node Costs — the page-cost arithmetic correlation feeds