cld-toys › Guided exercises › PostgreSQL

Query Planner Statistics

Every plan Postgres picks is a bet on a number it guessed. Watch the planner give the identical estimate for 990,000 rows and 1,000 rows, then watch a single stale number turn a 374 ms query into a 1270 ms one — without a character of the SQL changing.


Concept

EXPLAIN prints the planner's guess as rows=; EXPLAIN ANALYZE prints the truth next to it as actual rows=. When the two agree, the plan is usually good. When they disagree by a factor of a million, the planner chooses a Nested Loop where a Hash Join belonged — with the SQL text, the schema, the indexes and the data all completely unchanged.

This exercise is about where that guess comes from. You'll watch the planner give the identical row estimate for a value matching 990,000 rows and one matching 1,000; read the statistics ANALYZE writes down; then let those statistics go stale and watch one wrong number cascade into two wrong plan decisions and a 12 MB temp file on disk.


Mental model: what ANALYZE actually collects

ANALYZE takes a random sample of the table — 300 rows per unit of default_statistics_target, so 30,000 rows at the default of 100, regardless of whether the table has a million rows or a billion — and distills it into a handful of columns in the pg_stats view. That is the planner's entire picture of your data. Four things live there, and each rescues a different query shape:

StatisticWhat it holdsThe query shape it rescues
n_distinct Number of distinct values. Positive = an absolute count; negative = a ratio of the row count (-1 means "every value unique", and it stays correct as the table grows). GROUP BY col — how many groups to expect, and therefore how much hash memory. Also equality on any value the MCV list didn't capture: the estimate is just rows / n_distinct.
most_common_vals
+ most_common_freqs
The top N values by frequency (N = the statistics target) and each one's measured share of the table. WHERE col = 'x' on a skewed column. This is the only thing that lets the planner know 'archived' is 99% of the table and 'failed' is 0.1%. Without it, every value looks equally likely.
histogram_bounds N+1 boundary values dividing the non-MCV rows into equal-population buckets. Range predicates: WHERE col > 500, BETWEEN, <=. The planner interpolates within a bucket.
correlation How closely the column's sort order matches physical row order on disk, from -1 to 1. Not a row estimate at all — a cost input. Near 1 means an index scan reads the heap almost sequentially, so index scans get cheap; near 0 means random I/O, so the planner leans toward a seq scan or a bitmap scan.

Two things this table does not cover, and both bite in practice:

Statistics are a snapshot, not a stream They describe the table as of the last ANALYZE. Nothing recomputes them on write. In production the autovacuum daemon runs an "autoanalyze" once ~10% of the table has changed — which means a bulk load can leave a table's statistics describing a completely different table for as long as it takes that threshold to trip. Part 3 is that window.
Every column is assumed independent of every other one Postgres estimates WHERE a = 1 AND b = 2 by multiplying the two selectivities. When a and b are correlated, that multiplication is wrong by however correlated they are — and both individual estimates can be perfect while the combined one is off by 100×. Part 4 is that, and its fix is a fifth kind of statistic: CREATE STATISTICS, which you must ask for by name.

Finally, how to read the output. In EXPLAIN ANALYZE, every node prints both numbers:

-> Seq Scan on events (cost=0.00..41347.48 rows=1 width=4) (actual rows=1000000 loops=1) ^^^^^^ ^^^^^^^^^^^^^^ the guess the truth

Reading a plan is mostly scanning down the tree for the first node where those two diverge. Everything above it is built on that error.


Setup

One terminal. Everything below assumes you have Docker.

docker run --name pg-stats --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16
docker exec -it pg-stats psql -U postgres

Create two tables — an events fact table with a deliberately skewed status, and a small customers table to join against:

CREATE TABLE customers (
  id       int PRIMARY KEY,
  name     text,
  region   text
) WITH (autovacuum_enabled = off);

CREATE TABLE events (
  id           bigint,
  customer_id  int,
  status       text,
  city         text,
  country      text
) WITH (autovacuum_enabled = off);

INSERT INTO customers
SELECT g, 'customer-' || g, 'region-' || (g % 8)
FROM generate_series(1, 20000) g;

INSERT INTO events
SELECT g,
       1 + (g % 20000),
       CASE WHEN g % 1000 = 0 THEN 'failed'
            WHEN g % 100  = 0 THEN 'shipped'
            ELSE 'archived' END,
       'city-' || (g % 1000),
       'country-' || ((g % 1000) / 10)
FROM generate_series(1, 1000000) g;

The skew, which is the whole point:

SELECT status, count(*) FROM events GROUP BY status ORDER BY 2 DESC;
status | count ----------+-------- archived | 990000 shipped | 9000 failed | 1000 (3 rows)

Also note city and country: there are 1000 cities across 100 countries, and a city's name determines its country (city-7 is always in country-0). That's Part 4's material.

One setting before you start, purely for legibility:

SET max_parallel_workers_per_gather = 0;

With parallelism on, a Seq Scan node reports rows per worker (rows=1155 ... actual rows=330000 loops=3) and you have to multiply in your head. Turning it off makes every number in this exercise a whole-query total. It changes none of the conclusions.

Why autovacuum is off on both tables The autovacuum_enabled = off storage parameter turns off autoanalyze as well as autovacuum — and autoanalyze is precisely the thing that would notice your bulk insert, quietly refresh the statistics behind your back, and delete the effect you're trying to observe. This is a lab setting only — never do this to a real table; in production autoanalyze is exactly what you want running. Confirm it never fires with SELECT relname, last_autoanalyze FROM pg_stat_user_tables WHERE relname IN ('events','customers'); — both stay NULL for the entire exercise, through two million inserted rows.

Part 1 — a planner with no statistics at all

What this part tests: the claim in the mental model that the MCV list is "the only thing that lets the planner know 'archived' is 99% of the table and 'failed' is 0.1%". You've inserted a million rows but never run ANALYZE, so there is no MCV list. Watch what the planner does instead.

Step 1 · confirm the cupboard is bare
SELECT relname, relpages, reltuples FROM pg_class
WHERE relname IN ('events', 'customers');
SELECT count(*) AS stats_rows FROM pg_stats WHERE tablename = 'events';
relname | relpages | reltuples -----------+----------+----------- customers | 0 | -1 events | 0 | -1 (2 rows) stats_rows ------------ 0 (1 row)

relpages = 0 and reltuples = -1 are the "never analyzed" sentinels, and pg_stats has not one row about this table.

Step 2 · the same query, two literals
EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF)
  SELECT count(*) FROM events WHERE status = 'archived';
EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF)
  SELECT count(*) FROM events WHERE status = 'failed';
Predict: 'archived' matches 990,000 rows and 'failed' matches 1,000. What will the two rows= estimates be? Click to check.
Aggregate (cost=16183.93..16183.94 rows=1 width=8) (actual rows=1 loops=1) -> Seq Scan on events (cost=0.00..16177.00 rows=2773 width=0) (actual rows=990000 loops=1) Filter: (status = 'archived'::text) Rows Removed by Filter: 10000 Aggregate (cost=16183.93..16183.94 rows=1 width=8) (actual rows=1 loops=1) -> Seq Scan on events (cost=0.00..16177.00 rows=2773 width=0) (actual rows=1000 loops=1) Filter: (status = 'failed'::text) Rows Removed by Filter: 999000

rows=2773 both times. Not approximately the same — byte-for-byte identical, down to the cost estimate. One is wrong by 357× low, the other by 2.8× high, and the planner has no way to tell them apart, because as far as it knows status is a column of unknown contents and 'archived' and 'failed' are two opaque strings.

Where 2773 comes from With no MCV list, an equality predicate falls back to the hardcoded constant DEFAULT_EQ_SEL = 0.005 — the planner's blind guess that any = matches 0.5% of the table. And the table size is also guessed: with reltuples = -1 Postgres reads the file's actual page count and assumes an average row width, arriving at 554,640 rows (a bare EXPLAIN SELECT * FROM events; shows it). 0.005 × 554,640 = 2,773. Two guesses stacked, one of them a literal magic number.

Note the row-count guess is at least in the right ballpark (554k vs. 1M) — Postgres always checks the physical file size, so it is never wildly wrong about how big a table is. It is the distribution within the table that it knows nothing about, and that is what ANALYZE supplies.

Part 2 — what ANALYZE writes down

What this part tests: the four statistics in the mental-model table, read directly out of pg_stats the moment they come into existence.

Step 3 · run it, verbosely
ANALYZE VERBOSE events;
ANALYZE customers;
INFO: analyzing "public.events" INFO: "events": scanned 9244 of 9244 pages, containing 1000000 live rows and 0 dead rows; 30000 rows in sample, 1000000 estimated total rows

30000 rows in sample — 300 × default_statistics_target (100), the number from the top of the mental model, right there in the log. Note also 1000000 estimated total rows: even the row count is called an estimate, because on a large table ANALYZE extrapolates from the sampled pages rather than counting.

Step 4 · the skewed column
\x on
SELECT attname, null_frac, n_distinct, most_common_vals,
       most_common_freqs, correlation
FROM pg_stats WHERE tablename = 'events' AND attname = 'status';
\x off
-[ RECORD 1 ]-----+-------------------------- attname | status null_frac | 0 n_distinct | 3 most_common_vals | {archived,shipped,failed} most_common_freqs | {0.9903,0.0086,0.0011} correlation | 0.98001444

True frequencies are 0.990 / 0.009 / 0.001. From a 3% sample, the planner now has them to three decimal places. This pair of arrays is the fix for Part 1.

Step 5 · the whole table at a glance

This one query is the habit worth keeping:

SELECT attname, n_distinct, correlation,
       array_length(most_common_vals, 1)  AS n_mcv,
       array_length(histogram_bounds, 1)  AS n_hist
FROM pg_stats WHERE tablename = 'events' ORDER BY attname;
attname | n_distinct | correlation | n_mcv | n_hist -------------+------------+-------------+-------+-------- city | 1000 | 0.010646787 | 8 | 101 country | 100 | 0.018614726 | 100 | customer_id | 19913 | 0.019655457 | | 101 id | -1 | 1 | | 101 status | 3 | 0.98001444 | 3 | (5 rows)

Five columns, five different shapes, and every cell is the mental-model table in miniature:

Step 6 · re-run Part 1's two queries
Predict: both estimates will change — but does the planner get both the 990,000 and the 1,000 right? Click to check.
-> Seq Scan on events (cost=0.00..21744.00 rows=990300 width=0) (actual rows=990000 loops=1) Filter: (status = 'archived'::text) -> Seq Scan on events (cost=0.00..21744.00 rows=1100 width=0) (actual rows=1000 loops=1) Filter: (status = 'failed'::text)

rows=990300 vs. 990,000, and rows=1100 vs. 1,000. Both within 10%, from a sample of 3% of the table. This is the planner working correctly, and it is what "good statistics" looks like: two estimates that differ from each other by three orders of magnitude, because the data does.


Part 3 — stale statistics, and the plan they buy

What this part tests: the first caveat under the mental-model table — statistics are a snapshot. The statistics you just admired are accurate. Now the table changes underneath them, exactly as it would during a bulk load or a backfill, and nothing recomputes anything.

Step 7 · a batch job lands

A million new events, all in a status the table has never seen before:

INSERT INTO events
SELECT 1000000 + g, 1 + (g % 20000), 'pending',
       'city-' || (g % 1000), 'country-' || ((g % 1000) / 10)
FROM generate_series(1, 1000000) g;

The table is now 2,000,000 rows, exactly half of them 'pending'. Do not run ANALYZE.

EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF)
  SELECT count(*) FROM events WHERE status = 'pending';
Predict: 'pending' is not in the MCV list — it didn't exist when ANALYZE ran — and n_distinct says there are only 3 values, all 3 already in the MCV list, accounting for 100% of the table. So what selectivity is left for a fourth value? Click to check.
Aggregate (cost=41347.48..41347.49 rows=1 width=8) (actual rows=1 loops=1) -> Seq Scan on events (cost=0.00..41347.48 rows=1 width=0) (actual rows=1000000 loops=1) Filter: (status = 'pending'::text) Rows Removed by Filter: 1000000

rows=1actual rows=1000000. Six orders of magnitude. This is the arithmetic in the mental model running off a cliff: the selectivity for a value absent from the MCV list is (1 − Σ mcv_freqs) / (n_distinct − n_mcv), and here the MCV frequencies summed to essentially 1.0 while n_distinct − n_mcv is 0. There is no probability mass left to allocate. Postgres clamps the result to its floor: one row.

Notice what did not go wrong. The planner knows the table doubled — the cost rose from 21744 to 41347 because it re-checked the file size and scaled reltuples by the new page count. Table size self-heals; value distribution does not.

Step 8 · make something depend on it
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.region, count(*)
FROM events e JOIN customers c ON c.id = e.customer_id
WHERE e.status = 'pending'
GROUP BY c.region;
Predict: if the planner believes one row will come out of that scan, which join algorithm looks cheapest — and what does it cost when a million rows actually arrive? Click to check. (Run it twice; the second run is warm.)
GroupAggregate (cost=41355.79..41355.81 rows=1 width=17) (actual time=1133.075..1269.498 rows=8 loops=1) Group Key: c.region Buffers: shared hit=3016037 read=1544, temp read=1593 written=1605 -> Sort (cost=41355.79..41355.80 rows=1 width=9) (actual time=1115.167..1209.161 rows=1000000 loops=1) Sort Key: c.region Sort Method: external merge Disk: 12744kB -> Nested Loop (cost=0.29..41355.78 rows=1 width=9) (actual time=45.598..857.171 rows=1000000 loops=1) -> Seq Scan on events e (cost=0.00..41347.48 rows=1 width=4) (actual time=45.581..151.555 rows=1000000 loops=1) Filter: (status = 'pending'::text) Rows Removed by Filter: 1000000 -> Index Scan using customers_pkey on customers c (cost=0.29..8.30 rows=1 width=13) (actual time=0.001..0.001 rows=1 loops=1000000) Index Cond: (id = e.customer_id) Buffers: shared hit=3000000 Execution Time: 1270.444 ms

One wrong number, two wrong decisions:

  • Nested Loop. For a single outer row, one index probe into customers is unbeatable — building a hash table over 20,000 customers to join one row would be absurd. So the planner picks it, and then executes loops=1000000. That Buffers: shared hit=3000000 on the inner node is the damage in its purest form: exactly 3 buffer accesses per probe (root, leaf, heap page) × a million probes.
  • GroupAggregate with a Sort. For one row, sorting is free and avoids building a hash table. So it sorts — and a million rows don't fit in the default 4 MB work_mem, giving Sort Method: external merge  Disk: 12744kB. A misestimate wrote 12 MB of temp files to disk.
Step 9 · fix it without touching the query
ANALYZE events;
SELECT most_common_vals, most_common_freqs
FROM pg_stats WHERE tablename = 'events' AND attname = 'status';
most_common_vals | most_common_freqs -----------------------------------+------------------------------------------ {pending,archived,shipped,failed} | {0.4992,0.49596667,0.0046,0.00023333334} (1 row)

pending is now the first entry, at 49.9%.

Predict: re-run the identical SQL from Step 8 — same text, same schema, same indexes. What changes? Click to check.
HashAggregate (cost=50789.36..50789.44 rows=8 width=17) (actual time=374.382..374.384 rows=8 loops=1) Group Key: c.region Batches: 1 Memory Usage: 24kB Buffers: shared hit=16146 read=1580 -> Hash Join (cost=598.00..45797.36 rows=998400 width=9) (actual time=50.523..272.401 rows=1000000 loops=1) Hash Cond: (e.customer_id = c.id) -> Seq Scan on events e (cost=0.00..42578.00 rows=998400 width=4) (actual time=47.083..129.153 rows=1000000 loops=1) Filter: (status = 'pending'::text) Rows Removed by Filter: 1000000 -> Hash (cost=348.00..348.00 rows=20000 width=13) (actual time=3.359..3.360 rows=20000 loops=1) Buckets: 32768 Batches: 1 Memory Usage: 1194kB -> Seq Scan on customers c (cost=0.00..348.00 rows=20000 width=13) (actual time=0.014..1.401 rows=20000 loops=1) Execution Time: 374.473 ms
stale statisticsafter ANALYZE
scan estimaterows=1 (actual 1,000,000)rows=998400 (actual 1,000,000)
joinNested Loop, 1,000,000 loopsHash Join, one pass
groupingGroupAggregate + SortHashAggregate, 24 kB
temp files12,744 kB spilled to disknone
buffer accesses3,016,03716,146
wall clock1270 ms374 ms

3.4× faster wall-clock, and — the more transferable number, since it doesn't depend on your laptop — 187× fewer buffer accesses. The only thing that changed is one row in a statistics catalog.


Part 4 — two right answers that multiply into a wrong one

What this part tests: the second caveat under the mental-model table. Everything so far has been about a single column, where good statistics gave good estimates. Here both columns have excellent statistics and the estimate is still catastrophically wrong, because the error isn't in either column — it's in the assumption connecting them.

city and country are functionally dependent: a city-N is always in country-(N/10). Knowing the city tells you the country for free. The planner does not know that.

Step 10 · each predicate on its own
EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF)
  SELECT count(*) FROM events WHERE city = 'city-7';
EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF)
  SELECT count(*) FROM events WHERE country = 'country-0';
-> Seq Scan on events (cost=0.00..42578.00 rows=1990 width=0) (actual rows=2000 loops=1) Filter: (city = 'city-7'::text) -> Seq Scan on events (cost=0.00..42578.00 rows=18467 width=0) (actual rows=20000 loops=1) Filter: (country = 'country-0'::text)

1990 vs. 2000, and 18467 vs. 20000. Both within 8%. The statistics on these columns are fine.

Step 11 · both at once
EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF)
  SELECT count(*) FROM events
  WHERE city = 'city-7' AND country = 'country-0';
Predict: city = 'city-7' matches 2,000 rows, and every one of them is in country-0, so the second predicate removes nothing — the true answer is still 2,000. What will the planner estimate? Click to check.
-> Seq Scan on events (cost=0.00..47578.00 rows=18 width=0) (actual rows=2000 loops=1) Filter: ((city = 'city-7'::text) AND (country = 'country-0'::text)) Rows Removed by Filter: 1998000

rows=18, actual 2,000. 111× too low, built entirely out of two accurate estimates. Do the arithmetic the planner did: selectivity of the city predicate is 1990/2,000,000 ≈ 0.000995, of the country predicate 18467/2,000,000 ≈ 0.00923. It multiplies them — 0.000995 × 0.00923 × 2,000,000 ≈ 18 — because the cost model assumes independent columns. Here the second predicate has a true conditional selectivity of 1.0 given the first, and the planner applied 0.009.

This is the failure mode that no amount of ANALYZE will fix, because nothing about it is stale. It's an error in the model, not the data. And it compounds: three correlated predicates multiply three times.

Step 12 · tell the planner they're related
CREATE STATISTICS events_geo (dependencies) ON city, country FROM events;
ANALYZE events;
SELECT statistics_name, attnames, dependencies FROM pg_stats_ext
WHERE tablename = 'events';
Predict: CREATE STATISTICS is a multivariate statistics object you must request by name — Postgres will never build one on its own, because tracking every column pair would be quadratic. What do you expect it to store about (city, country)? Click to check.
statistics_name | attnames | dependencies -----------------+----------------+---------------------- events_geo | {city,country} | {"4 => 5": 1.000000} (1 row)

One number. 4 => 5 is by attribute number — column 4 (city) determines column 5 (country) — and 1.000000 is the degree of that dependency, on a 0-to-1 scale. A perfect functional dependency, so the planner can now apply the country predicate's selectivity as 1.0 instead of 0.009. A partial dependency like 0.7 gets partially applied; this isn't an on/off switch.

Re-run the same two-predicate query:

-> Seq Scan on events (cost=0.00..47578.00 rows=1989 width=0) (actual rows=2000 loops=1) Filter: ((city = 'city-7'::text) AND (country = 'country-0'::text))

rows=18rows=1989, against an actual 2,000. The query is unchanged, the data is unchanged, and the statistics on the individual columns are unchanged. All that changed is that the planner was told the two columns are related.


Part 5 — how much detail is ANALYZE keeping?

What this part tests: default_statistics_target, the dial controlling both the sample size and the length of the MCV list and histogram. The received wisdom is "raise it when estimates are bad". This part is worth doing because the result is not what that advice implies.

Step 13 · the current resolution
SHOW default_statistics_target;
SELECT attname, n_distinct,
       array_length(most_common_vals, 1) AS n_mcv,
       array_length(histogram_bounds, 1) AS n_hist
FROM pg_stats WHERE tablename = 'events'
  AND attname IN ('customer_id', 'city') ORDER BY attname;
default_statistics_target --------------------------- 100 attname | n_distinct | n_mcv | n_hist -------------+------------+-------+-------- city | 1000 | 11 | 101 customer_id | 20032 | 2 | 101 (2 rows)

1000 cities, of which 11 are individually tracked. customer_id is estimated at 20,032 distinct; the true value is exactly 20,000.

Step 14 · turn the dial up 10×
ALTER TABLE events ALTER COLUMN city SET STATISTICS 1000;
ALTER TABLE events ALTER COLUMN customer_id SET STATISTICS 1000;
ANALYZE VERBOSE events;
INFO: "events": scanned 17578 of 17578 pages, containing 2000000 live rows and 0 dead rows; 300000 rows in sample, 2000000 estimated total rows
Predict: ANALYZE just sampled 300,000 rows instead of 30,000. What improves — the MCV list, n_distinct, or the row estimates? Click to check.
attname | n_distinct | n_mcv | n_hist -------------+------------+-------+-------- city | 1000 | 1000 | customer_id | 20000 | 104 | 1001 (2 rows)

Two real gains. city now has every one of its 1000 values in the MCV list — and consequently no histogram at all, because once the MCV list covers the entire column there is no tail left to bucket (exactly the status/country shape from Part 2, arrived at by turning a dial). And customer_id's n_distinct went from 20,032 to exactly 20,000, which matters for GROUP BY customer_id: the planner now sizes its hash table correctly.

Now check what it bought for the row estimate that motivated all this:

EXPLAIN (ANALYZE, TIMING OFF, BUFFERS OFF)
  SELECT count(*) FROM events WHERE city = 'city-7';
-> Seq Scan on events (cost=0.00..42578.00 rows=2047 width=0) (actual rows=2000 loops=1)

rows=1990rows=2047. Ten times the sample, ten times the ANALYZE cost, ten times the planning-time work of scanning that MCV array — and the estimate got very slightly worse. Both are within 3% of the truth; the difference is sampling noise.

The honest lesson, and it's the opposite of the folklore A bigger statistics target buys resolution, and resolution only helps where there is structure too fine for 100 buckets to capture: a long-tailed column where the 150th most common value is still 100× more frequent than average, or a GROUP BY whose hash sizing depends on n_distinct. On a uniform column — this one, by construction — it buys nothing but a slower ANALYZE. Raise it per-column, in response to a specific bad estimate you have measured, never globally on reflex.

What you should see

A planner with no statistics giving the identical rows=2773 estimate for a value matching 990,000 rows and one matching 1,000 — the same number twice, because a hardcoded 0.005 is all it has. Then ANALYZE, and those estimates separating to 990,300 and 1,100 against actuals of 990,000 and 1,000.

Then a bulk insert going in behind the statistics' back, producing rows=1 ... actual rows=1000000 — and that one number driving the planner into a Nested Loop executed a million times (3,016,037 buffer accesses) and a sort that spills 12 MB to disk, 1270 ms in total. A bare ANALYZE — no change to the query, the schema, or the indexes — turning it into a Hash Join and a HashAggregate: 16,146 buffer accesses, no temp files, 374 ms.

Then the failure ANALYZE can't fix: two predicates estimated at 1990 and 18467 against actuals of 2000 and 20000, whose conjunction is estimated at 18 against an actual 2000, because the planner multiplied two selectivities that were not independent. CREATE STATISTICS ... (dependencies) records a single number — "4 => 5": 1.000000 — and the estimate becomes 1989.

And a control result: raising default_statistics_target from 100 to 1000 moves a row estimate from 1990 to 2047, i.e. nowhere, while making ANALYZE sample ten times as much data.


Why

Because a query planner is a cost minimiser, and every cost it computes is a function of a row count it cannot know without running the query. So it estimates — and the estimate is built from a fixed-size random sample taken at some point in the past. Both of those phrases are load-bearing.

Fixed-size is why the sample is 30,000 rows whether the table has a million rows or a billion: statistics have to be cheap to maintain, so they buy accuracy on frequencies (which converge fast with sample size) at the cost of accuracy on cardinalities (n_distinct, which converges slowly and is why Part 5 showed 20,032 instead of 20,000).

In the past is Part 3. There is no incremental maintenance of a histogram; nothing on the write path touches pg_statistic. The reconciliation is a background job with a threshold, and between the write and the threshold tripping, the planner is confidently reasoning about a table that no longer exists. Every "the query was fast yesterday" incident after a bulk load is this.

Why underestimation is more dangerous than overestimation Postgres clamps every estimate to at least one row, so the most wrong an estimate can be on the low side is unbounded — while the plan it produces looks maximally attractive: nested loops, index probes, no hash tables, no sorting. All the plans that are brilliant for one row and ruinous for a million. That is why rows=1 next to a big actual rows is the single most valuable pattern to recognise in an EXPLAIN ANALYZE.

Part 4 is a different species of wrong. Nothing is stale and no sample is too small; the model is wrong. Multiplying selectivities is the textbook independence assumption, and it is chosen because the alternative — storing joint distributions for every combination of columns — is combinatorially impossible to do by default. So Postgres does the cheap thing universally and offers CREATE STATISTICS as an opt-in for the specific column groups you know are related. That's a deliberate trade: the database will be wrong about your correlated columns until you tell it they're correlated.


Go deeper

Sources: PostgreSQL Docs: Statistics Used by the Planner · Row Estimation Examples, which works the selectivity arithmetic by hand · CREATE STATISTICS for the three kinds of extended statistics


Cleanup

docker stop pg-stats