PostgreSQLVIII · VACUUM, Autovacuum and WraparoundVacuum
ANALYZE and planner statistics
What you'll learn
- Explain what ANALYZE collects and how the planner uses each piece
- Diagnose a bad plan as a statistics problem rather than an index problem
- Apply default_statistics_target and extended statistics where each belongs
- Recognise the situations where autoanalyze will not fire in time
Prerequisites
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
ANALYZE shares a command with VACUUM and an autovacuum daemon with
it, but it solves a different problem. Vacuum manages space. Analyze
manages the planner’s model of your data, and when that model is wrong
the planner makes confident, catastrophic choices.
What it costs to skip
A million-row table. One value, rare, occurs once per thousand rows;
common is everything else. An index exists on the column. The table
has been loaded but not analysed.
$ psql -U postgres -c "EXPLAIN (ANALYZE, BUFFERS, COSTS ON, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM stats_demo WHERE category='rare'" -> Bitmap Heap Scan on stats_demo (cost=59.17..5640.65 rows=5000 width=0) (actual rows=1000.00 loops=1)
Recheck Cond: (category = 'rare'::text)
Heap Blocks: exact=1000
Buffers: shared hit=1000 read=3$ psql -U postgres -c "ANALYZE stats_demo" -c "EXPLAIN (ANALYZE, BUFFERS, COSTS ON, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM stats_demo WHERE category='rare'" -> Index Only Scan using stats_demo_cat_idx on stats_demo (cost=0.42..24.85 rows=1167 width=0) (actual rows=1000.00 loops=1)
Index Cond: (category = 'rare'::text)
Heap Fetches: 0
Buffers: shared hit=41003 buffers to 4. The estimate moved from 5,000 to 1,167 against an actual of 1,000, and that was enough to change the plan from a bitmap heap scan touching a thousand pages to an index-only scan touching four.
Without statistics the planner falls back on defaults — for an equality test on a text column, a fixed selectivity guess. It was not being stupid; it had nothing to work with.
What ANALYZE stores
$ psql -U postgres -x -c "SELECT attname, null_frac, n_distinct, most_common_vals, most_common_freqs, avg_width FROM pg_stats WHERE tablename='stats_demo' AND attname='category'"attname | category
null_frac | 0
n_distinct | 2
most_common_vals | {common,rare}
most_common_freqs | {0.99883336,0.0011666666}
avg_width | 6| Statistic | What the planner does with it |
|---|---|
null_frac | Estimates IS NULL and IS NOT NULL |
n_distinct | Estimates equality selectivity, and group counts for GROUP BY |
most_common_vals / _freqs | Exact selectivity for the listed values |
histogram_bounds | Range selectivity for values not in the MCV list |
correlation | How well physical order matches logical order, which decides index scan cost |
n_distinct is stored as a negative number when it scales with the
table — -1 means every value is unique, -0.5 means distinct values
are half the row count. A positive number is an absolute count. Reading
-1 as “one distinct value” is a common and expensive
misinterpretation.
Where statistics go wrong
Correlated columns
The planner assumes columns are independent. When they are not, it multiplies selectivities that should not be multiplied.
$ psql -U postgres -c "EXPLAIN (ANALYZE, COSTS ON, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM corr WHERE a=5 AND b=5" -> Parallel Seq Scan on corr (cost=0.00..11690.00 rows=45 width=0) (actual rows=3333.33 loops=3)45 estimated per worker against 3,333 actual. The planner took 1/100 for
a=5, 1/100 for b=5, multiplied them, and got 1/10,000 of the table.
$ psql -U postgres -c "CREATE STATISTICS corr_ab (dependencies, ndistinct) ON a, b FROM corr" -c "ANALYZE corr" -c "EXPLAIN (ANALYZE, COSTS ON, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM corr WHERE a=5 AND b=5" -> Parallel Seq Scan on corr (cost=0.00..11690.00 rows=3820 width=0) (actual rows=3333.33 loops=3)45 to 3,820, against an actual of 3,333.
The plan did not change here, because a sequential scan was correct either way. That is worth being precise about: the estimate is what improved, and the estimate is what a join above this node would be planned from. A 74-fold underestimate feeding a join is how you get a nested loop over ten thousand rows instead of a hash join.
Real cases where this bites: city and postcode, country and
currency, product and category, any status column paired with a
type column.
CREATE STATISTICS orders_geo (dependencies, ndistinct, mcv)
ON city, postcode FROM orders;
ANALYZE orders;
mcv adds multi-column most-common-value lists, which handles skew that
dependencies alone does not.
Sample size
default_statistics_target is 100, meaning ANALYZE samples roughly
300 × 100 = 30,000 rows and keeps up to 100 MCVs and 100 histogram
buckets.
That is enough for most columns and not enough for a column with high cardinality and heavy skew — one where a few values dominate but there are thousands of distinct values, so the important ones fall outside the top 100.
-- per column, which is almost always the right scope
ALTER TABLE orders ALTER COLUMN customer_id SET STATISTICS 1000;
ANALYZE orders;
Raising it globally makes every ANALYZE slower and every plan
marginally more expensive to produce. Raise it on the column that needs
it, having identified that column from a plan whose estimate is wrong.
When autoanalyze is not enough
Autoanalyze fires at autovacuum_analyze_threshold (50) plus
autovacuum_analyze_scale_factor (0.1) times live tuples, counting
inserts, updates and deletes. It handles the ordinary case. Four
situations where it will not:
Immediately after a bulk load or restore. Nothing has run yet.
ANALYZE explicitly — and note that pg_restore does not do it for
you.
After a schema change. ALTER TABLE … ADD COLUMN with a default, or
a type change, invalidates what was known about that column.
On a large, append-only table with a time column. 10% of a billion
rows is a hundred million inserts before autoanalyze fires. Meanwhile
every query filtering on created_at > now() - interval '1 hour' is
asking about a range beyond the last histogram bucket, where the planner
estimates almost nothing matches. This is the single most common
statistics failure in production, and it is invisible until a plan flips.
ALTER TABLE events SET (autovacuum_analyze_scale_factor = 0.01);
On a partitioned table. Statistics on the parent are not
maintained by autovacuum in the way per-partition statistics are.
Queries planned against the parent may need ANALYZE parent_table run
explicitly, and this is a well-known source of surprise on partitioned
schemas.
What to take from this
- Statistics changed one measured query from 1003 buffers to 4. They are not optional after a load.
n_distinctnegative means “scales with the table”.-1is unique, not one.- The planner assumes column independence.
CREATE STATISTICSis the fix, and it fixes the estimate even when the plan is unchanged. - Diagnose from estimate against actual, bottom up, at the first diverging node.
- Autoanalyze misses bulk loads, schema changes, large append-only tables and partitioned parents. Handle those explicitly.
Cross-course references
- Observability for Production Sysadmins — Part LIX (Database
observability) covers exporting
last_analyzeandn_mod_since_analyze, which are what turn a stale-statistics incident into a threshold. - Git, CI/CD & GitOps — Part CXIV (Deployment markers) covers annotating a bulk load on the same timeline as the plan regression it causes.
Quiz
Knowledge check · 6 questions
Q1. A query filtering on created_at > now() - interval '1 hour' against a billion-row append-only table has suddenly become very slow, with no schema or query change. What is the most likely cause?
Q2. pg_stats reports n_distinct as -1 for a column. What does that mean?
Q3. EXPLAIN ANALYZE shows accurate row counts at every scan node but a large overestimate at the top-level join. Where should the investigation go?
Q4. Which situations call for running ANALYZE explicitly rather than relying on autoanalyze? Select all that apply.
Q5. Extended statistics were worth creating in the measured example even though the chosen plan did not change.
Q6. Describe how you would read an EXPLAIN ANALYZE plan to decide whether a bad plan is a statistics problem.
Passing score: 75%. Answers are checked in this browser.