Partial Index Scan Degradation
degradedSummary
When a query uses a B-tree index on a low-selectivity column (one where the filter matches most rows), the index scan reads all or most index pages plus all the corresponding heap pages, performing more total I/O than a sequential scan would. The query planner may choose the index scan based on stale statistics that underestimate the selectivity, causing a query that should take 50ms on a sequential scan to take 5 seconds on an index scan because it reads the heap via thousands of random I/O operations instead of one sequential pass.
Description
Index scans are efficient when they retrieve a small fraction of table rows (high selectivity). A query fetching 1% of rows via an index reads 1% of heap pages (random I/O) plus the relevant index pages: far less total I/O than a full sequential scan. But when a query retrieves 40% or more of rows, an index scan can be more expensive than a sequential scan: it reads 40% of heap pages as random I/O (each page access requiring a separate disk seek on HDDs, or a separate I/O request on SSDs), plus the full index leaf level. A sequential scan reads 40% of the table as contiguous sequential I/O, which modern storage executes at 4–20x the throughput of random I/O.
The PostgreSQL query planner estimates the cost of each plan using column statistics collected by ANALYZE (stored in pg_statistic). If statistics are stale (collected when the data distribution was different) or the planner's default selectivity assumptions are inaccurate (e.g., for complex predicates involving type casts, LIKE patterns, or correlated columns), the planner may estimate 1% selectivity when the actual selectivity is 40%. It chooses the index scan expecting cheap I/O but executes expensive random I/O across 40% of the table.
The most common production trigger is a data distribution shift. A table is initially 90% inactive (archived) and 10% active. The index on status = 'active' returns 10% of rows (high selectivity), so the planner correctly chooses the index scan. Over time, more records become active (business growth, migration failure). Now 85% of records are active. The same query on status = 'active' returns 85% of rows (low selectivity). If ANALYZE has not run since the distribution shifted, the planner still estimates 10% selectivity, uses the index scan, and performs 8.5x more random heap I/O than it estimated.
The planning error is confirmed by EXPLAIN ANALYZE: the "Rows Removed by Filter" count is near zero (the filter is not removing many rows after the index lookup), and the actual rows/estimated rows ratio shows a large discrepancy. The fix is to run ANALYZE on the affected table to update statistics, but this is often not obvious to engineers who assume autovacuum/autoanalyze is keeping statistics current (it may not be, if the table is large and autoanalyze thresholds are not calibrated for the data distribution shift rate).
Characteristics
Triggers
- ·Data distribution on an indexed column shifts significantly between ANALYZE runs (active/archived ratio changes, status column distribution changes)
- ·ANALYZE is not run after bulk data import that changes column value distribution
- ·autovacuum/autoanalyze is configured with default thresholds (20% of table changed triggers analyze) which are too coarse for large tables (200M rows table requires 40M row changes to trigger analyze)
- ·Column statistics are collected with insufficient histogram buckets (default_statistics_target=100) for a highly non-uniform distribution
Detection Signals
Mitigation Strategies
Execute ANALYZE table_name (or ANALYZE table_name (column_name) for targeted analysis) to refresh the planner statistics for the affected table. The planner will re-estimate selectivity based on the current data distribution and may switch from an index scan to a sequential scan. This is the immediate fix; it takes seconds to minutes for large tables. After running ANALYZE, use EXPLAIN (without ANALYZE) to verify the planner now chooses a sequential scan for the affected query.
Increase the column-level statistics target: ALTER TABLE t ALTER COLUMN status SET STATISTICS 500; then ANALYZE t. A higher statistics target collects more histogram buckets, giving the planner a more accurate selectivity estimate for skewed distributions. Default is 100 buckets; 500 provides much better estimates for non-uniform distributions. The ANALYZE runtime increases proportionally, but the improvement in plan quality for complex predicates is substantial.
Override the per-table autovacuum threshold to trigger ANALYZE more frequently: ALTER TABLE large_table SET (autovacuum_analyze_scale_factor=0.01, autovacuum_analyze_threshold=1000). This triggers ANALYZE after 1% of rows change (instead of the default 20%), ensuring statistics are refreshed after smaller data distribution shifts. For a 200M row table, the default threshold requires 40M row changes; the tuned threshold triggers at 2M changes, 20x more responsive to distribution shifts.
For queries with a stable filter predicate that currently covers a small fraction of rows (WHERE status = 'pending' returns 2% of rows), create a partial index: CREATE INDEX CONCURRENTLY idx_pending ON orders (id) WHERE status = 'pending'. The partial index only covers the pending rows, making its selectivity always high regardless of the overall table distribution. As the total pending count grows, query the fraction covered by the index periodically and recreate it as a broader partial index if needed.
Recovery Steps
- 1.Run EXPLAIN ANALYZE on the slow query to confirm index scan with actual rows >> estimated rows
- 2.Execute ANALYZE table_name immediately: query planner will use updated statistics for subsequent queries
- 3.Re-run EXPLAIN (without ANALYZE) to verify the planner now chooses a sequential scan or a different index
- 4.Check autovacuum history for the table via pg_stat_user_tables.last_autoanalyze to determine why statistics were stale
- 5.If autovacuum is not running frequently enough, adjust autovacuum_analyze_scale_factor for the table
Estimated recovery time: 2–10 minutes: ANALYZE completes in seconds to minutes for most tables; query latency normalizes immediately after the planner uses updated statistics for new queries. In-flight queries using the old plan complete with the slow plan but are not interrupted.
Affected Systems
Patterns
Technologies
Basis
PostgreSQL query planner statistics mechanics and selectivity estimation are precisely documented in PostgreSQL documentation; random vs sequential I/O cost differential is empirically well-characterized; autovacuum analyze threshold calculations are deterministic from documented defaults; data distribution shift as a trigger is a well-documented production scenario