Skip to main content
RunBook Academy

PostgreSQLX · Query Planning, Indexes and Performance MethodPlanner

When estimates go wrong

Advanced⏱ ~35 min🧪 Lab requiredpsql

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

Not yet marked complete on this device.

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.

Read-only / Safethe plan before extended statistics
$ 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 ms

The 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.

Configuration changethe same query after teaching the planner the columns are dependent
$ 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 ms

Estimate 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;
KindFixes
dependenciesWHERE a = x AND b = y where a determines b
ndistinctGROUP BY a, b estimating the wrong number of groups
mcvSkew 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) = $1 has no statistics for lower(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 VOLATILE function. 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 Loop with a large loops count 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_limit the written join order is preserved; beyond geqo_threshold plans 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

  1. 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?

  2. 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?

  3. Q3. A predicate of the form WHERE lower(email) = $1 is badly estimated no matter how much ANALYZE is run. Why, and what helps?

  4. Q4. Which predicates are opaque to per-column statistics in a way that no amount of ANALYZE will fix? Select all that apply.

  5. Q5. A query joining more than geqo_threshold tables can produce different plans on different executions with identical data.

  6. 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.