Parameterized Inner Index Scans and Loops #
The inner index scan of a nested loop reports actual time=0.021..0.024 rows=3 loops=184000. It looks like the fastest node in the plan. It is actually responsible for 4.4 seconds of a 5-second query, because every number on that line is an average per loop, and the loop ran 184,000 times.
Nested loops are the most common join in OLTP plans and the easiest to misread. When the planner chooses them at all is covered in merge join vs nested loop; this page covers reading and tuning the parameterized inner side.
The Algorithmic Condition #
A nested loop reads one row from its outer input, then executes its inner input for that row, then reads the next outer row. When the inner input is an index scan whose condition refers to a column of the current outer row, it is a parameterized path: the outer value is passed in as a parameter, and the plan shows it in the inner node’s Index Cond, such as (customer_id = o.customer_id).
The planner prefers this shape when:
- the outer input is estimated to be small, so the number of inner executions is low;
- an index exists whose leading column matches the join key, so each inner execution is a short descent;
- the join returns few rows per outer row.
The cost is roughly outer rows × (index descent + heap fetches per match). Nothing is built in memory and nothing spills, which is why nested loops are ideal for small outer sets and catastrophic for large ones. The same parameterization appears under LATERAL subqueries, correlated EXISTS, and index-backed anti-joins, and can be wrapped in a Memoize cache when outer values repeat.
Parameterized paths can also appear below other nodes: the planner may push the outer value through a join or an append into several index scans, producing Index Cond lines that reference relations outside their own subtree. Those are still per-loop executions and still need multiplying.
Annotated EXPLAIN Evidence #
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.placed_at >= now() - interval '2 days';
Nested Loop (actual time=0.06..5021.8 rows=184002 loops=1)
-> Index Scan using orders_placed_at_idx on orders o
(cost=0.44..8120.10 rows=21040 width=16) (actual time=0.03..402.6 rows=184002 loops=1)
Index Cond: (placed_at >= (now() - '2 days'::interval))
-> Index Scan using customers_pkey on customers c
(cost=0.43..0.46 rows=1 width=40) (actual time=0.021..0.024 rows=1 loops=184002)
Index Cond: (id = o.customer_id) -- parameterized by the outer row
Buffers: shared hit=412008 read=140120
Execution Time: 5104.6 ms
-- inner total time ≈ 0.024 ms × 184002 loops ≈ 4.4 s
-- outer estimate 21040 vs actual 184002: the planner expected 21k loops, not 184k
The arithmetic:
- Inner total time =
actual total time × loops=0.024 × 184002≈ 4,416 ms. - Inner total rows =
rows × loops= 184,002. - Buffers on the inner node are already totals across loops: 552,128 buffers, about three per loop — an index descent plus a heap page.
- Why the plan was chosen: the outer estimate of 21,040 made 21,040 probes look cheap. With 184,002 probes, a hash join over
customerswould have been competitive.
Step-by-Step Resolution #
-
Multiply before judging. For every inner node with
loops > 1, computeactual total time × loops. Rank nodes by that total. -
Compare actual loops with the outer node’s estimate. A large gap means the choice of nested loop rested on an underestimate — the fix is upstream, in the outer node’s statistics; see spotting row estimate explosions.
-
Check per-loop buffers. Divide inner
Buffersbyloops. Two or three per loop is an efficient probe; tens per loop means many heap fetches per key or a poorly correlated index. -
Make each probe cheaper when the loop count is legitimately high: an index-only scan avoids heap fetches entirely.
CREATE INDEX CONCURRENTLY customers_id_email_idx ON customers (id) INCLUDE (email); VACUUM (ANALYZE) customers; -- keeps the visibility map current for index-only scans -
Or let the planner choose a set-based join once estimates are right, and confirm the alternative:
BEGIN; SET LOCAL enable_nestloop = off; EXPLAIN (ANALYZE, BUFFERS) <query>; ROLLBACK; -
Check for repeated outer keys. If many outer rows share the same
customer_id, a Memoize node or pre-aggregation of the outer side cuts the loop count.
Before and After #
-- BEFORE: heap fetch per probe
Index Scan using customers_pkey (actual time=0.021..0.024 rows=1 loops=184002) Buffers: read=140120 Execution Time: 5104.6 ms
-- AFTER: covering index, visibility map current
Index Only Scan using customers_id_email_idx (actual time=0.006..0.007 rows=1 loops=184002) Heap Fetches: 0 Execution Time: 1702.3 ms
Parameterized paths that are not joins #
The same per-loop reading applies wherever a node executes more than once. A correlated SubPlan in a SELECT list shows loops equal to the number of rows it was evaluated for. The inner side of a LATERAL join shows loops per outer row. A Memoize node’s child shows loops equal to cache misses rather than outer rows, which is exactly how you tell whether the cache is working. And under Gather, loops counts parallel participants, not repetitions — a scan with loops=5 under a four-worker Gather ran once in each process. Reading loops in its context is the single most important habit for interpreting any EXPLAIN ANALYZE output, and the underlying arithmetic is covered in reading cost vs actual time in EXPLAIN ANALYZE.
Common Pitfalls #
Ranking nodes by per-loop time. The inner probe looks negligible. Diagnostic signal: the real bottleneck hidden under a tiny actual time. Fix: multiply by loops first.
Multiplying buffers by loops. Buffer counts are already totals. Diagnostic signal: I/O figures that exceed the table size several times over. Fix: divide by loops for per-probe cost; never multiply.
Forcing hash joins globally. enable_nestloop = off harms the many plans where nested loops are right. Diagnostic signal: widespread regressions. Fix: correct the outer estimate or scope the setting to one transaction.
Missing the index on the join key. Without it, the inner side becomes a sequential scan per loop. Diagnostic signal: Seq Scan with loops in the thousands. Fix: index the inner join column.
Frequently Asked Questions #
What does loops mean on an inner index scan? #
It is the number of times the node was executed — once per outer row in a nested loop. The node’s actual time and rows are averages per execution, so multiply them by loops to get totals.
Are Buffers in EXPLAIN per loop or total? #
Total. Buffer counts are accumulated across all executions of the node, so divide by loops to see the I/O cost of a single probe.
When is a nested loop with many loops acceptable? #
When each probe is a short index descent with few buffers — ideally an index-only scan — and the total inner time is small relative to the query. It becomes the wrong plan when the outer row count was badly underestimated and a set-based join would process the rows in one pass.
Related #
- Merge Join vs Nested Loop — parent guide: algorithm selection
- Forcing a Nested Loop for Small Inner Tables — sibling: when to push the planner toward loops
- Building Effective Covering Indexes — making each probe index-only