Spotting Row-Estimate Explosions in Nested Loops #
The most punishing single failure mode in PostgreSQL query plans is a nested loop chosen on a one-row estimate that turns out to match tens of thousands of rows. A nested loop is cheap when its outer side yields a handful of rows — the inner side runs a few times. But when the planner estimates one row and the reality is 50,000, that same nested loop executes its inner scan 50,000 times, and a query that should take milliseconds runs for minutes. The plan node looks innocuous; the loops= counter is where the damage hides.
This page teaches you to read that counter and trace the explosion to its statistical root cause. It belongs under Identifying Plan Bottlenecks; the general skill of comparing estimates to reality is covered in reading cost vs actual time in EXPLAIN ANALYZE.
Anatomy of the Explosion #
A nested loop’s true cost is not the inner node’s printed actual time — it is that time multiplied by loops. EXPLAIN ANALYZE reports the inner node’s timing per loop, so a modest-looking inner scan hides its total under a large loops count.
Reading the Explosion in EXPLAIN ANALYZE #
Take a query joining orders to line items, filtered by two correlated order attributes:
EXPLAIN (ANALYZE)
SELECT o.order_id, li.sku, li.quantity
FROM orders o
JOIN line_items li ON li.order_id = o.order_id
WHERE o.channel = 'wholesale' AND o.priority = 'expedite';
Nested Loop (cost=0.85..14.20 rows=1 width=32)
(actual time=0.09..2140.6 rows=182000 loops=1) -- ← est 1, actual 182000
-> Index Scan using idx_orders_channel_priority on orders o
(cost=0.42..6.10 rows=1 width=8)
(actual time=0.03..46.2 rows=50000 loops=1) -- ← outer: est 1, actual 50000
Index Cond: ((channel = 'wholesale') AND (priority = 'expedite'))
-> Index Scan using idx_line_items_order on line_items li
(cost=0.43..8.05 rows=4 width=28)
(actual time=0.01..0.03 rows=4 loops=50000) -- ← inner ran 50000 loops
Index Cond: (order_id = o.order_id)
The diagnosis is entirely in the numbers:
- The outer
Index Scanestimatedrows=1but returnedactual rows=50000. The planner multiplied the selectivities ofchannel = 'wholesale'andpriority = 'expedite'as independent, and the product collapsed to one row. - The inner
Index Scanshowsloops=50000. Its per-loop time of ~0.03 ms looks harmless, but0.03 ms × 50000 ≈ 1500 msof inner work, plus overhead — matching the 2.1-second total on the nested loop. - The inner node is not slow. Each probe is a fast
Index Cond. The problem is that a nested loop was chosen at all, because the outer estimate said it would run once.
Had the planner known the outer side yields 50,000 rows, it would have built the line_items side into a hash table once and probed it — a hash join costing a fraction of 182,000 nested probes.
Root Causes of the One-Row Estimate #
Three related conditions produce the collapsed estimate:
- Correlated predicates.
channel = 'wholesale'andpriority = 'expedite'are not independent — expedite orders concentrate in the wholesale channel. The planner assumes independence and multiplies their selectivities, driving the estimate to one row. - Joins on non-independent columns. When a join key correlates with a filtered column, the same independence assumption undercounts the surviving rows.
- Missing extended statistics. Without a multivariate statistics object, PostgreSQL has no data telling it the columns move together, so it cannot avoid the multiplication.
Step-by-Step: Diagnose and Fix #
Step 1 — Find the estimate/actual gap #
Run EXPLAIN (ANALYZE) and scan for a node whose estimated rows is 1 (or single digits) while actual rows is large — especially when it feeds the outer side of a Nested Loop.
Step 2 — Quantify the true inner cost #
On the inner node, multiply per-loop actual time by loops. That product, not the printed per-loop figure, is the real cost. A loops=50000 with even 0.03 ms each is the bottleneck.
Step 3 — Identify the correlated columns #
Determine which predicates or join columns collapsed the estimate. Check whether two filtered columns are semantically linked, or whether a join key correlates with a filter.
Step 4 — Create extended statistics and re-analyze #
Teach the planner the correlation with a multivariate statistics object covering distinct-value combinations:
CREATE STATISTICS orders_channel_priority (ndistinct, dependencies)
ON channel, priority FROM orders;
ANALYZE orders;
Now the planner estimates the joint selectivity from real data instead of multiplying, and the outer estimate rises toward 50,000 — which makes the nested loop look expensive and a hash join look cheap.
Step 5 — Apply a stopgap if the query is failing now #
If the query is timing out in production before you can ship statistics, force the alternative for the session:
SET LOCAL enable_nestloop = off; -- stopgap only; forces a hash join for this session
EXPLAIN (ANALYZE)
SELECT o.order_id, li.sku, li.quantity
FROM orders o
JOIN line_items li ON li.order_id = o.order_id
WHERE o.channel = 'wholesale' AND o.priority = 'expedite';
This rescues the immediate query but disables nested loops for every join in the session, so it is a bridge to the statistics fix, not the fix itself.
Step 6 — Validate the natural plan #
Re-run EXPLAIN (ANALYZE) with enable_nestloop back on. With correct statistics the planner should estimate the true outer count and choose a Hash Join unprompted.
Before/After Plan Comparison #
-- BEFORE: est 1 row → nested loop, inner scan runs 50000 loops
Nested Loop (actual time=0.09..2140.6 rows=182000 loops=1) est rows=1
-> Index Scan on orders (actual rows=50000) est rows=1
-> Index Scan on line_items (actual rows=4 loops=50000) -- ← 50000 probes
-- AFTER: extended stats fix the estimate → hash join, one build
Hash Join (actual time=52.1..238.7 rows=182000 loops=1) est rows=50000
Hash Cond: (li.order_id = o.order_id)
-> Index Scan on orders (actual rows=50000) est rows=50000 -- ← estimate now correct
-> Hash -> Seq Scan on line_items (actual rows=750000 loops=1) -- ← built once
The 50,000 inner probes collapse to a single hash build, and the join’s actual time falls from 2.1 seconds to a quarter-second.
Common Pitfalls #
Treating the symptom, not the cause. Leaving enable_nestloop = off in place “fixes” one query while silently forbidding nested loops everywhere, including the many queries where a nested loop is correct. Ship the extended statistics and revert the flag.
Missing the multi-column nature of the correlation. Creating statistics on the wrong pair of columns leaves the estimate collapsed. Confirm which columns actually co-vary — sometimes it is a filter column correlated with the join key, not two filter columns, that drives the miss. The underlying selectivity math is detailed in cost estimation models.
A LIMIT tricking the planner. With LIMIT, the planner may favor a nested loop expecting to stop early after finding a few rows. If the matching rows are sparse, it scans far more than it estimated. Watch for LIMIT queries whose nested-loop inner loops far exceed the limit.
Assuming a hash join is always the answer. Once statistics are correct the planner may still, legitimately, choose a nested loop for a genuinely small outer side — that is the right call. Force a hash join only when the estimate is wrong, not whenever you see a nested loop.
Frequently Asked Questions #
How do I spot a row-estimate explosion in a nested loop?
Look for a nested loop whose outer side shows estimated rows near 1 but actual rows in the thousands, with an inner node showing a matching loops count. The planner chose the nested loop believing the inner side would run once or twice; the loops value reveals it ran tens of thousands of times. Multiply the inner node’s per-loop actual time by loops for the true cost.
What causes PostgreSQL to estimate one row when there are thousands? Usually correlated predicates or a join on non-independent columns. PostgreSQL multiplies the selectivities of separate conditions as if independent, and when they actually correlate the product collapses toward one row. Missing extended statistics leave the planner unable to know the columns move together.
Should I set enable_nestloop = off to fix the explosion?
Only as a stopgap. Turning it off for the session forces a hash join and can rescue a query that is timing out now, but it treats the symptom. The durable fix is CREATE STATISTICS on the correlated columns followed by ANALYZE, so the planner estimates the real row count and chooses the right join on its own.
Related
- Identifying Plan Bottlenecks — parent cluster: locating the hot node in a multi-operator plan
- Reading & Interpreting Query Plans — grandparent pillar: the full method for reading execution plans
- Reading Cost vs Actual Time in EXPLAIN ANALYZE — how per-loop timing and loops combine into real cost
- Cost Estimation Models — the selectivity math that produces the collapsed estimate
- Hash Join Mechanics — the join strategy the corrected plan switches to