Statistics and the misestimate
The planner estimates rows from pg_statistic — n_distinct, MCV lists, histograms. Correlated columns break the independence assumption; CREATE STATISTICS is the fix before the misestimate cascades into a bad join.
A dashboard query that joins orders to line_items and filters WHERE city = 'Paris' AND country = 'France' ran in 40 ms for a year. One morning it’s 22 seconds. Nothing deployed, no data spike. EXPLAIN ANALYZE shows the filter estimated 12 rows, actual 480,000 — a 40,000× miss. The planner, believing 12 rows, chose a nested loop and is now probing an index 480,000 times. The data didn’t change; the correlation between two columns finally broke the planner’s math. This lesson is that bug and its one-line fix.
What the planner knows about your data
Before you can fix a bad estimate, you need to know what the planner is reading. Every estimate the planner makes (lesson 02) comes from one place: the pg_statistic catalog, readable through the pg_stats view. ANALYZE (run manually, or automatically by autovacuum — lesson 04) samples each table and stores three things per column. The databases track’s statistics chapter covers the sampling internals; here we read these as the levers a SQL author pulls:
n_distinct— how many distinct values the column has. Drives “how many rows match one value?” → roughlyrow_count / n_distinct.- Most Common Values (
most_common_vals+most_common_freqs) — the top values and their exact frequencies. Forstatus = 'placed'the planner doesn’t guess; it reads the stored frequency. This is why skewed columns estimate well for the common values. - Histogram (
histogram_bounds) — buckets of equal row-count for everything not in the MCV list, used for range predicates liketotal > 100.
-- Inspect what the planner believes about a column:
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';default_statistics_target (default 100) controls how many MCV entries and histogram buckets are kept. Raise it on a column with many distinct values or heavy skew (ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;) to trade a slower ANALYZE for sharper estimates.
The correlated-columns trap
Here’s the math that breaks. By default the planner estimates a multi-column filter by assuming the columns are independent: it multiplies their individual selectivities. For WHERE city = 'Paris' AND country = 'France':
estimate = P(city='Paris') × P(country='France') × row_countIf 1% of rows are Paris and 2% are France, the planner predicts 0.01 × 0.02 = 0.0002 → 0.02% of rows. But city and country are functionally dependent — every Paris row is a France row. The real selectivity is just 1%, fifty times higher. The planner under-estimates by ~50×, expects a handful of rows, picks a nested loop, and detonates when reality delivers 480,000.
This is the most common senior-level misestimate in production, and it is invisible until a join is built on top of it. The independence assumption is fine for genuinely unrelated columns; it’s catastrophic for correlated ones (city/country, brand/model, zip/state, status/shipped_at).
The fix is extended statistics — you tell Postgres these columns travel together:
CREATE STATISTICS orders_city_country (dependencies, ndistinct, mcv)
ON city, country FROM orders;
ANALYZE orders; -- extended stats only populate on the next ANALYZEThree kinds, each fixing a different miss:
dependencies— captures functional dependency (Paris ⇒ France), fixing the multiplied-selectivity under-estimate above.ndistinct— captures the combined distinct count of a column group, fixingGROUP BY a, bestimates (independence over-counts groups).mcv— a multi-column most-common-values list for skewed combinations.
All three target the same root: the planner was reasoning about columns in isolation. Without dependencies, you get the nested-loop explosion; without ndistinct, GROUP BY over-predicts groups and wastes memory on a too-large hash table; without mcv, rare-combination queries get a generic estimate that can be off in either direction.
After CREATE STATISTICS + ANALYZE, the same query re-plans: estimate 480,000, actual 480,000, planner picks a hash join, 22 s → 60 ms. No query rewrite, no index, no hint — you corrected the estimate.
▸Why this works
Why doesn’t Postgres just gather multi-column stats automatically for every pair? Combinatorics. A 30-column table has 435 column pairs and far more triples; auto-collecting all of them would make ANALYZE quadratically expensive and bloat the catalog. So Postgres defaults to the cheap independence assumption and lets you opt in with CREATE STATISTICS exactly on the column groups you know are correlated. Finding them is detective work: scan EXPLAIN ANALYZE for nodes where estimated and actual rows diverge sharply on a multi-column filter — that gap is the signature of a missing extended-statistics object.
WHERE city='Paris' AND country='France' estimates 12 rows but actually returns 480,000. What is the root cause?
Which CREATE STATISTICS kind fixes the under-estimate from multiplying two correlated single-column selectivities?
Fill in the blank: by default the planner assumes columns are _______, so it multiplies their selectivities; for correlated columns this under-estimates badly until CREATE STATISTICS tells it the truth.
- 01What three statistics does ANALYZE store per column and what is each used for?
- 02Why does WHERE city='Paris' AND country='France' get badly under-estimated, and how do you fix it?
- 03How does a single misestimate cascade into a bad join, and how do you spot it?
Every planner estimate traces back to pg_statistic, populated by ANALYZE: n_distinct (rows per value), the MCV list (exact frequencies for common values, so skew estimates well), and a histogram (range predicates). Raise default_statistics_target per column to sharpen them. The senior trap is the independence assumption: for a multi-column filter the planner multiplies single-column selectivities, which is fine for unrelated columns but under-estimates correlated ones — city='Paris' AND country='France' can miss by 40,000×. That single misestimate cascades: believing a handful of rows, the planner picks a nested loop that then probes an index hundreds of thousands of times, turning 40 ms into 22 s. The fix is CREATE STATISTICS (dependencies, ndistinct, mcv) on the correlated group plus an ANALYZE — you repair the estimate and the planner re-derives a hash join on its own. Hunt these by scanning EXPLAIN ANALYZE for big estimate-vs-actual gaps on multi-column predicates.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.