Read the loops multiplier in EXPLAIN ANALYZE
Inner node timings in EXPLAIN ANALYZE are per loop, not totals. A step reporting 0.001 ms looks free and is not, because the plan ran it two thousand times — and the buffer count is where that hides.
Problem
What you're actually looking at
The symptom as it shows up on a real server.
EXPLAIN ANALYZE reports actual time and rows for the average execution of a node, not the sum across executions. Under a nested loop the inner side runs once per outer row, so the number you read has to be multiplied by loops before it means anything — and the node that dominates a query is routinely the one displaying the smallest number.
A Meridian join takes far longer than the sum of its visible node timings. The inner index scan reports a fraction of a millisecond and is dismissed as trivial. It ran once for every outer row, and the buffers it touched account for almost the entire cost of the query.
In plain English
EXPLAIN ANALYZE puts two numbers on every step: how long it took and how many rows it returned. What is easy to miss is that for steps inside a loop, those are per-run averages rather than totals. A nested loop join runs its inner step once for each row coming from the outer step, so a step that reads "0.001 ms" and "1 row" may actually have run two thousand times. The time you should compare against the query total is the reported time multiplied by the loop count, and the same applies to rows. The reliable giveaway is the buffer count, because that one is a genuine total: when a step reports a rounding error for time but thousands of buffers, the loops are where your query went.
Full runbook for this incident
- The full identify checklist — the exact signals that tell you it's this incident
- Every diagnostic query; PostgreSQL 18 output is attached only to the steps we captured
- The resolution path and the pitfalls that make it worse
- Mitigation steps to stop it recurring, plus a verify-you're-done query
More in this category
Other Query performance runbooks
Neighbouring incidents that share the same diagnostic surface.
Connected
How this connects to the rest of the library
A live view of this page's real cross-references — what explains it, what fixes it, what to tune, and where to go next. Every link is an authored relationship, not a guess.
Fixes these errors