PostgreSQLX · Query Planning, Indexes and Performance MethodPlanner
When estimates go wrong
What you'll learn
- Locate the first node in a plan where estimate and actual diverge
- Classify an estimate error by which mechanism produced it
- Apply the correct remedy for each class rather than guessing
- Explain why a nested loop turns an estimate error into an outage
Prerequisites
Practice
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
The planner is not intelligent and does not need to be. It is arithmetic over statistics, and when its plans are bad the statistics were wrong.
This lesson is about finding out which statistic, because the four common causes have four different remedies and applying the wrong one is how a query stays slow through three rounds of tuning.
The measurement
Two columns, a and b, both computed as g % 100, so a = 7 implies
b = 7 and exactly 1,000 rows of 100,000 match. The planner assumes the
columns are independent.
$ psql -U postgres -c "EXPLAIN (ANALYZE, BUFFERS, COSTS ON, TIMING ON, SUMMARY ON) SELECT count(*) FROM ev JOIN dim ON dim.id=ev.dim_id WHERE dim.a=7 AND dim.b=7" -> Seq Scan on dim (cost=0.00..2041.00 rows=12 width=4) (actual time=0.028..4.929 rows=1000.00 loops=1)
Filter: ((a = 7) AND (b = 7))
Rows Removed by Filter: 99000
-> Bitmap Heap Scan on ev (cost=4.66..122.37 rows=30 width=4) (actual time=0.061..0.276 rows=30.00 loops=1000)
Recheck Cond: (dim_id = dim.id)
Heap Blocks: exact=30000
Buffers: shared hit=6082 read=26918 written=14884
Execution Time: 289.282 msThe planner took 1/100 for a=7, 1/100 for b=7, multiplied them and
estimated 1/10,000 of the table — twelve rows. A nested loop over twelve
rows is obviously right. A nested loop over a thousand rows is not, and
it ran a thousand times, touching about 33,000 buffers.
$ psql -U postgres -c "CREATE STATISTICS dim_ab (dependencies, ndistinct, mcv) ON a, b FROM dim" -c "ANALYZE dim" -c "EXPLAIN (ANALYZE, BUFFERS, COSTS ON, TIMING ON, SUMMARY ON) SELECT count(*) FROM ev JOIN dim ON dim.id=ev.dim_id WHERE dim.a=7 AND dim.b=7" -> Hash Join (cost=2053.84..51926.22 rows=12769 width=0) (actual time=2.793..94.136 rows=10000.00 loops=3)
Hash Cond: (ev.dim_id = dim.id)
-> Parallel Seq Scan on ev (cost=0.00..46591.00 rows=1250000 width=4) (actual rows=1000000.00 loops=3)
-> Hash (cost=2041.00..2041.00 rows=1027 width=4) (actual rows=1000.00 loops=3)
-> Seq Scan on dim (cost=0.00..2041.00 rows=1027 width=4) (actual rows=1000.00 loops=3)
Filter: ((a = 7) AND (b = 7))
Execution Time: 101.732 msEstimate 1,027 against actual 1,000 — 2.7% out, from 83× out. The plan changed from a nested loop to a hash join, and the query from 289.282 ms to 101.732 ms.
Classifying the error
Read the plan bottom up. Find the first node where estimate and actual diverge materially. Everything above it is downstream of that error and will look wrong as a consequence.
Then classify:
Class 1 — stale statistics
Signature. A single-column predicate, off by any amount, and
last_autoanalyze is old or n_mod_since_analyze is large.
SELECT relname, n_mod_since_analyze, last_autoanalyze, last_analyze
FROM pg_stat_user_tables WHERE relname = 'orders';
Fix. ANALYZE. If it recurs, lower
autovacuum_analyze_scale_factor for that table — Part VIII covers the
append-only-table case where 10% of a billion rows is never reached in
time.
Class 2 — inadequate sample
Signature. A single-column predicate on a high-cardinality, skewed column. Statistics are fresh and still wrong.
Fix. Raise the sample for that column only.
ALTER TABLE orders ALTER COLUMN customer_id SET STATISTICS 1000;
ANALYZE orders;
The default target of 100 keeps 100 most-common values and 100 histogram buckets. A column with thousands of distinct values and heavy skew needs more of them.
Class 3 — correlated columns
Signature. Each predicate is estimated well alone; combining them is badly wrong. Check by running each half separately.
Fix. CREATE STATISTICS, as measured above.
CREATE STATISTICS orders_geo (dependencies, ndistinct, mcv)
ON city, postcode FROM orders;
ANALYZE orders;
| Kind | Fixes |
|---|---|
dependencies | WHERE a = x AND b = y where a determines b |
ndistinct | GROUP BY a, b estimating the wrong number of groups |
mcv | Skew in specific combinations that dependencies averages over |
Class 4 — the planner cannot know
Signature. The predicate is opaque to statistics.
- A function result.
WHERE lower(email) = $1has no statistics forlower(email). An expression index creates them:CREATE INDEX ON users (lower(email))— the index maintains statistics on the expression as a side effect. - A join across tables. Correlation between columns in different tables is not modelled by anything. Extended statistics are per table.
- A
LIKE '%…%'. Leading wildcards defeat histogram reasoning. - A
VOLATILEfunction. The planner cannot assume anything about its output.
Fix. Restructure the query, or accept it and give the planner a better path — an expression index, a materialised column, a different join order.
What to take from this
- Find the first diverging node, bottom up. Everything above it is a consequence.
- Nested loops multiply estimate errors. A
Nested Loopwith a largeloopscount and a small estimate is the signature. - Four classes: stale statistics, inadequate sample, correlated columns, and opaque predicates. Different fixes.
- Running each predicate separately separates class 2 from class 3 in seconds.
- Extended statistics moved a measured estimate from 12 to 1,027 against an actual of 1,000, and the query from 289 ms to 102 ms.
- Beyond
join_collapse_limitthe written join order is preserved; beyondgeqo_thresholdplans become non-deterministic.
Cross-course references
- Observability for Production Sysadmins — Part LIX (Database
observability) covers exporting
n_mod_since_analyze, which is where a stale-statistics estimate error is visible before the plan flips. - Linux for Production Sysadmins — Part LXXXII (Root cause analysis) covers distinguishing the estimate error that caused the regression from the several that did not.
Quiz
Knowledge check · 6 questions
Q1. A query that ran in 50 ms for months now takes twenty minutes. No code or schema changed. The plan shows a Nested Loop whose inner node reports rows=30 and loops=400000. What happened?
Q2. Two predicates each estimate accurately when tested alone, but combining them in one WHERE clause produces an estimate 80 times too low. What is the diagnosis?
Q3. A predicate of the form WHERE lower(email) = $1 is badly estimated no matter how much ANALYZE is run. Why, and what helps?
Q4. Which predicates are opaque to per-column statistics in a way that no amount of ANALYZE will fix? Select all that apply.
Q5. A query joining more than geqo_threshold tables can produce different plans on different executions with identical data.
Q6. Describe the four queries you would run to classify an estimate error, and what each one rules out.
Passing score: 75%. Answers are checked in this browser.