Topic 49

When the Planner Gets It Wrong

Troubleshooting

The planner is a cost model, and a cost model is only as good as what it is told. Almost every story that begins "the planner is stupid" ends in one of five places: a statistic that was never collected, a statistic that is stale, two predicates estimated as independent when they are not, a cost constant describing hardware nobody has run since 2005, or a generic plan built without the parameter value that would have changed it. None of those is a planner defect, and all of them are findable in under ten minutes.

This topic is the procedure for finding them, and it ends by closing the book's first wound. On the first of each month Cartwheel's checkout latency climbs from 40 ms into whole seconds, six of them on the morning of Saturday the 1st of August, and recovers hours later with nobody touching anything. Nothing was deployed. No host is short of CPU. The Saturday after carried the same traffic and behaved perfectly. All of that is true, all of it is consistent with one mechanism, and the evidence has been printable since Chapter 1.

The Procedure

Run EXPLAIN (ANALYZE) on the statement, wrapping it in BEGIN … ROLLBACK if it writes. Then walk the tree from the bottom and find the lowest node where the estimated row count and the actual row count diverge sharply. That node is the problem. Everything above it was priced from that node's output: the join type, the join order, the sort that spilled, the aggregate that took four seconds. Every one of those choices is a consequence rather than a cause.

This ordering is the whole method, not a style preference. Time is reported at the top of the plan, so the top is where attention goes, and the top is almost never where the fault is. Fix the estimate and the plan usually reassembles itself; fix something above it and you have improved a plan that should never have been chosen. A node whose estimate is off by a factor of three hundred thousand hands a false number to every decision above it.

The Five Usual Causes

Once you have the divergent node, the cause is nearly always in a short list. Stale or absent statistics: the table was bulk-loaded, restored, or has simply changed more than autovacuum's threshold has noticed. Correlated predicates: two columns filtered together whose selectivities were multiplied as if independent, fixed with a CREATE STATISTICS object. A value the statistics have never seen: rows past the histogram's last boundary, or a value missing from the most-common list. An expression the planner cannot estimate: a function call or computed value in the WHERE clause, for which there is no distribution at all, fixed with an expression index or expression statistics. And a cost model that does not match the hardware: random_page_cost at 4.0 on NVMe and effective_cache_size at its 4 GB default on a 64 GB machine, which together tell the planner that index scans are several times more expensive than they are.

Keep the fifth separate from the other four. It is a configuration problem, it applies to every query on the server equally, it is fixed once, and it belongs in the baseline Chapter 10 builds rather than in a conversation about one slow query.

Five causes, and where each one is actually fixed
The table was bulk-loaded or restored, or has changed more than autovacuum noticed→An ANALYZE
Two columns filtered together, their selectivities multiplied as if independent→A CREATE STATISTICS object
The predicate selects rows the statistics have never seen→A finer target, analyzed sooner
A function call or computed value in the WHERE clause, with no distribution at all→An expression index or expression statistics
A cost model describing hardware nobody has run since 2005→Cluster configuration, fixed once

Parameterized Plans

A prepared statement is planned with its actual parameter values for the first five executions. Postgres averages the cost of those five custom plans, then builds one generic plan with the parameters left as placeholders and compares. If the generic plan's estimated cost is not much worse than the average custom cost, it is used from then on and no further planning happens. That is a good trade for most statements and a trap for any statement whose parameter values differ wildly in selectivity — a customer with eleven orders and a customer with 400,000 get the same plan, and one of them is badly served by it.

The tell is a query that is "randomly" slow in a pattern nobody can reproduce by hand, because psql sends literals and gets a custom plan every time. SET plan_cache_mode = force_custom_plan, per session or per role, makes Postgres replan on every execution: it costs planning time and buys correct plans. EXPLAIN (GENERIC_PLAN) on the statement text with $1 in place shows the generic plan without executing anything. And since most drivers prepare statements automatically, this happens in applications that never wrote the word PREPARE. What a pooler does to those prepared statements is Chapter 10's problem.

Solving the Saturday

Cartwheel's checkout renders the customer's orders for the current month, and the business counts months in Berlin time. The query joins orders to the order_statuses lookup for a readable label, filters on one customer_id, and compares the order's calendar month with the current one, each computed by date_trunc with the zone pinned inside the call, exactly as Chapter 8 advised, so that an expression index, orders_placed_month_idx, could be built on it. It returns twenty-one rows. On the morning of the 1st of August it took 6.3 seconds, and the plan from the diagnostics topic says why in one line: the outer scan was estimated at rows=1 and reported Rows Removed by Filter: 300461.

Now the mechanism, end to end. ANALYZE collects statistics for the expression an index is built on, and a calendar month over four years of orders has 48 distinct values, few enough that every one of them goes into the most-common list. The list covers the whole table. now() is stable, so the current month is folded to a constant while the statement is planned, and on the 1st of August that constant is a month the list has never seen, because the last autoanalyze ran at 23:00 on the 31st of July. An equality against a value not in the list gets whatever the list leaves over, divided among the values it does not name; this list leaves nothing, so the estimate drops to the floor of one row. One row makes orders_placed_month_idx the cheapest path in the query at a cost of 4.47, against about 174 for walking the customer's orders through orders_customer_history_idx, an index-only scan that still has to visit the heap for every one of them because the scattered writes keep the visibility map clear. So the planner takes the month index and applies customer_id as a filter afterwards. In reality that index scan walks the entire month, all 300,482 orders for every customer, fetching each one from the heap. New rows land wherever vacuum has left free space, so the walk reads 196,155 pages, none of them already in a 128 MB shared_buffers, and 196,155 device reads at about thirty-two microseconds each is the 6.3 seconds.

The Saturday, end to end
The new month is not in the list1 August · 48 months listed, the last one July
→
The estimate collapsesrows=1, the floor under a remainder of nothing
→
A single-row plan winsmonth index, customer_id as a filter
→
6.3 seconds300,482 rows read, 300,461 discarded
The same query after ANALYZE, on the same data, run a second time
 Nested Loop  (cost=0.56..174.88 rows=1 width=42)
              (actual time=0.010..0.104 rows=21.00 loops=1)
   Join Filter: (s.status = o.status)
   Rows Removed by Join Filter: 52
   Buffers: shared hit=97
   ->  Index Only Scan using orders_customer_history_idx on orders o
         (cost=0.56..173.72 rows=1 width=39)
         (actual time=0.007..0.084 rows=21.00 loops=1)
         Index Cond: (customer_id = 88213)
         Filter: (date_trunc('month'::text, placed_at, 'Europe/Berlin'::text)
                = date_trunc('month'::text, now(), 'Europe/Berlin'::text))
         Rows Removed by Filter: 51
         Heap Fetches: 72
         Index Searches: 1
         Buffers: shared hit=76
   ->  Seq Scan on order_statuses s  (cost=0.00..1.07 rows=7 width=21)
         (actual time=0.000..0.000 rows=3.48 loops=21)
         Buffers: shared hit=21
 Planning:
   Buffers: shared hit=4
 Planning Time: 0.063 ms
 Execution Time: 0.110 ms

Nothing about the query changed. With August in the list at its real share, 0.75% of the table, the month index would have to walk some 300,000 entries, so the planner goes through orders_customer_history_idx instead: it walks the customer's 72 orders in the index, visits the heap for each, drops the 51 from earlier months with the filter, and returns the twenty-one. The node still estimates one row, because a customer's share of a month's share comes out below one, and that no longer matters: the price of this path comes from the customer's index entries, not from that final number. Ninety-seven buffer hits and a tenth of a millisecond, or 7.4 milliseconds the first time, with most pages coming off the device. The difference came from a statistic, not from a rewrite and not from an index that did not already exist.

The fix, one per-table setting
ALTER TABLE orders SET (autovacuum_analyze_scale_factor = 0.002,
                        autovacuum_analyze_threshold    = 5000);

The per-table override drops the autoanalyze trigger from 4,000,050 changed tuples to 85,000, which at Cartwheel's volume is minutes rather than days: the first autoanalyze of the new month finds it thousands of orders deep and lists it at its real share, long before the morning's checkouts. An ANALYZE scheduled for a minute after midnight would not do it. At that point the month holds a few hundred of forty million rows, a 30,000-row sample almost never draws one, and a value drawn only once is not listed. The longer-term fix is the other half of Chapter 8's advice: write the month as a range on the bare column, placed_at >= date_trunc('month', now(), 'Europe/Berlin'). Then orders_customer_history_idx takes both predicates as index conditions, the range is estimated from the real newest value at the end of orders_placed_at_idx instead of a list of months, and the 1st stops being special. Chapter 1's loose ends close here as well. It always started on the 1st because that is when the month changes. It lasted until autoanalyze next ran, which at a few hundred orders a minute takes the best part of two days and on the 1st of August took the Saturday burst a morning. And nothing in the application, a restart included, could have fixed it.

Rewrites That Help the Planner

Some queries are hard to estimate because of how they are written, and four rewrites come up often enough to be worth memorizing. Replacing NOT IN (SELECT …) with NOT EXISTS lets the planner use an anti-join instead of a construct whose null semantics block it. Moving a function call off the column, so placed_at >= '2026-08-01' rather than date_trunc('day', placed_at) = '2026-08-01', restores both the index and the statistics, because the planner has a distribution for the column and none for the computed value. Splitting an OR across two different columns into a UNION ALL of two queries gives each branch its own index. And a LIMIT the planner can push down into a scan turns "sort ten million rows and take twenty" into "read twenty".

These are a different lever from hinting. A hint tells the planner to ignore what it computed; a rewrite changes what it computes, by supplying a statistic it can use or opening a path it could not otherwise take, and the result keeps adapting as the data does.

The Escape Hatches, and Their Price

The enable_* settings are diagnostics, not fixes. SET enable_nestloop = off in a session is the fastest way to prove that the nested loop is what is costing you the six seconds, because the planner will produce its next-best plan and you can time it. Written into postgresql.conf, the same setting distorts every plan on the server to avoid a node type that is correct most of the time, and it hides the estimate error that caused the original problem — which will now express itself somewhere else.

pg_hint_plan is the honest version of the same instinct: real directives in the query, pinning a join method or an index. Under time pressure, with users watching, both are legitimate, and both freeze a decision that was correct against one day's data distribution. A hint left in place after its cause is fixed is a query that can no longer benefit from anything you do later. Use them to stop the bleeding, write the real cause in the same commit, and give the workaround a removal date with a name against it.

Fixing the Estimate vs Forcing the Plan

Fixing the estimate — an ANALYZE, a statistics target, an extended statistics object, a rewrite. Slower to arrive at, and it keeps the planner adaptive: when the data shifts again, the plan shifts with it and nobody has to be paged.

Forcing the plan — an enable_* toggle or a pg_hint_plan directive. Instant and durable, and it pins a decision made against today's distribution. Right for the twenty minutes an incident is live, wrong as a permanent state, and always paired with a note saying what the real cause was.

Common Mistakes
  • Optimizing the top of the plan, where the time is reported, instead of the lowest node whose estimate diverges — the top node is a consequence of the bottom one.
  • Writing enable_seqscan = off into postgresql.conf — every plan in the cluster is now distorted to avoid a node type that is correct for most queries on most tables.
  • Adding an index in response to a bad plan without checking the estimate first — you end up with an unused index, its write cost on every insert, and the original bug.
  • Calling a query "randomly slow" when the pattern is a generic plan taking over after five executions of a prepared statement, which psql with literals will never reproduce.
  • Leaving a pg_hint_plan directive in a query after the statistics problem behind it is fixed, so the query is permanently locked out of any better plan.
  • Accepting "restarting the service fixed it" as a diagnosis — a pooled connection holding a cached generic plan produces exactly that evidence, for exactly the wrong reason.
Best Practices
  • Follow the divergence: lowest node, largest ratio between estimated and actual rows, and fix that node's statistics before touching anything else.
  • Prefer statistics and rewrites over hints, and treat every hint as temporary with a named owner and a removal date in the commit that added it.
  • Set plan_cache_mode = force_custom_plan for the specific statements whose parameter selectivity varies by orders of magnitude.
  • Turn on auto_explain with a duration threshold so the pathological execution is captured when it happens, since it will not reproduce on demand.
  • Lower the per-table analyze threshold on any large table whose hot queries filter on the newest rows, before the month boundary arrives rather than after.
  • Record the estimated and actual row counts before and after any fix, so the change is justified by the number that moved rather than by the query feeling faster.
Comparable toolsOracle SQL plan baselines and adaptive plansSQL Server Query Store with forced planspg_hint_plan in-query hints for Postgresauto_explain captures the plan you cannot reproducehypopg tests an index before building it

Knowledge Check

Where do you start when a plan is wrong?

  • At the top node, since that is where the execution time is reported
  • At the lowest node where estimated and actual row counts diverge sharply
  • At whichever node carries the largest total cost figure in the plan
  • At any sequential scan, since those indicate a missing or unusable index

What is a generic plan, and when does Postgres switch to one?

  • A plan built without parameter values, considered after five custom plans
  • A fallback plan used when a table has no collected statistics at all
  • A simplified plan used once a query exceeds geqo_threshold relations
  • A plan shared across every backend to avoid replanning the same statement

Why did Cartwheel's month predicate estimate one row on the first of August?

  • The customer-history index had become invalid and could not be used
  • August was missing from a most-common list that covered every row
  • The date_trunc call cannot be estimated, so a default guess was used
  • The low correlation on placed_at drove the row estimate down to one

Why does lowering the analyze threshold on orders end the problem?

  • It rebuilds the month index so that its entries are no longer stale
  • It puts the new month into the list before checkout needs it
  • It creates the extended statistics the two columns were missing
  • It raises the statistics target so the histogram gains buckets

When is SET enable_nestloop = off a defensible thing to do?

  • In postgresql.conf, once nested loops have caused a production incident
  • In one session, to confirm the nested loop is the cost and time the alternative
  • On the application role, so the application never receives a nested loop
  • As a default on any server where the largest table exceeds a million rows

You got correct