Skip to main content

Command Palette

Search for a command to run...

PostgreSQL Nested Loop: When Is It Actually a Problem?

Updated
3 min readView as Markdown

PROBLEM

A Nested Loop join runs its inner side once per row on the outer side. That's the cheapest possible join when the outer side is small — and a real cost multiplier once it isn't. The operator itself is never the problem; the outer row count and the inner side's per-execution cost are.

HOW POSTGRESQL EXPOSES IT

Run EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON). A Nested Loop node has exactly two children — Plans[0] is the outer side, Plans[1] the inner. The inner child's own "Actual Loops" tells you how many times it ran; its "Actual Total Time" is already an average per loop, not a sum — multiply the two yourself to get real total cost.

"Plans": [
  { "Node Type": "Seq Scan", "Actual Rows": 25000, "Actual Loops": 1 },   // outer
  { "Node Type": "Index Scan", "Actual Rows": 1,
    "Actual Loops": 25000, "Actual Total Time": 0.05 }                    // inner
]

HEALTHY EXAMPLE

NO FINDING

Outer rows:  200
Inner loops: 200, avg 0.05ms each
Total inner-side work: ~10ms

Outer side is nowhere near large enough for the multiplication to matter. A Nested Loop over a small outer side is usually the fastest join Postgres has.

PROBLEMATIC EXAMPLE

FIRES — WARNING

Outer rows:  25,000
Inner loops: 25,000, avg 0.05ms each
Total inner-side work: ~1,250ms

Each inner execution is individually cheap — the cost is purely the sheer number of times it runs.

HOW TO INVESTIGATE

  • Check whether the outer row count itself is expected — is a filter earlier in the plan more selective than it should be?

  • Check the inner side's own per-loop time: is it cheap-but-frequent (fix the outer count) or genuinely slow-per-execution (fix the inner side)?

  • EXPLAIN (ANALYZE, BUFFERS) on the inner side alone, standalone, to see what it costs in isolation.

WHAT NOT TO ASSUME

Operator ≠ problem. A Nested Loop is not "the slow join type" — it's the correct choice whenever the outer side is small, full stop.

Materiality matters. 200 loops at 0.05ms each is noise. 25,000 loops at the same per-loop cost is 1.25 seconds of real work — same operator, same per-loop cost, different verdict, because the count changed.

No index is recommended without evidence. This pattern alone doesn't tell you which fix applies — reducing the outer count or speeding up the inner side are both real options, and only the plan's own numbers (which one dominates) point at which.

PLANREADER EXAMPLE

PlanReader's nested-loop-explosion rule fires only when the outer side clears 10,000 rows and the inner side loops 10,000+ times and the cumulative inner-side time (loops × per-loop time) clears 1 second — three independent gates, not one. Below 1 second cumulative, it stays silent even if the loop count alone looks alarming. Above 10 seconds cumulative, it escalates to critical. It also tells you which fix applies: cheap-per-loop points at the outer row count, expensive-per-loop points at the inner side itself — never a blanket "add an index" guess.

Related reading: Cardinality Estimation Errors (a bad row estimate is a common cause of a runaway outer side) · Memoize (the cache Postgres adds specifically to this join's inner side) · the full EXPLAIN guide's own join-strategies section.

ANALYZE YOUR PLAN

See exactly which side of your own Nested Loop is driving the cost — paste your EXPLAIN into PlanReader. Free, no signup, nothing leaves your browser.

More from this blog

Kiran Sabne — PostgreSQL, AI & Backend Engineering at Scale

15 posts

Engineering Notes by Kiran Sabne — deep dives into PostgreSQL performance tuning, database internals, AI & vector search workloads, CDC pipelines, and backend systems at scale. I'm a backend and database engineer with 8+ years working on production PostgreSQL, Aurora/RDS, Go, AI & Machine learning. This blog covers real production incidents, query optimization, indexing strategies, replication, and data pipeline design — the stuff you learn debugging systems at 3am. New posts weekly.