Deep Technical Analysis: Optimizing Nested Loop Left Joins by Short-Circuiting Inner Scans
The Core Problem
When PostgreSQL executes a Nested Loop Left Join where the join condition (ON clause) contains predicates referencing only the outer (non-nullable) table, the executor unnecessarily scans the inner relation even when those predicates are guaranteed to evaluate to false for a given outer row.
Consider:
SELECT * FROM parent p LEFT JOIN child c ON p.id = c.id AND p.dtype = 'B' WHERE p.id = 1;
The predicate p.dtype = 'B' references only the outer table (parent). If the outer row has dtype = 'A', the join condition will inevitably fail regardless of what the inner scan returns. Yet PostgreSQL still performs the index scan on child — wasted I/O and CPU.
Why This Can't Simply Be Pushed Down
The critical semantic distinction that makes this non-trivial: this is NOT equivalent to moving the condition to WHERE. In a LEFT JOIN, when the ON condition fails, the outer row is still emitted with NULLs for the inner columns. Moving p.dtype = 'B' to WHERE would eliminate outer rows entirely — a semantic change. Tom Lane immediately identified this constraint, and Andres Freund acknowledged his initial suggestion of pushdown was incorrect.
The existing optimizer already handles the symmetric case: conditions in the ON clause that reference only the nullable (inner) side are recognized and pushed down (since they can safely filter inner rows without affecting outer row emission). But the non-nullable-side-only case was never implemented because it's semantically different — you can't filter outer rows, you can only skip the inner scan.
Real-World Prevalence
This pattern is not merely academic:
- Hibernate/JPA ORM — The
treat()function for polymorphic queries with fetched associations generates exactly this pattern automatically - Product catalog systems — Tables with category-specific detail tables joined conditionally (vehicles, phones, etc.)
- PostgreSQL's own codebase —
psql'sdescribeOneTableDetails()andpg_dump'sgetTables()contain similar patterns
Proposed Solutions
Approach 1: Executor-Level NestLoop Modification (Lepikhov/Petrov)
The initial patch by Andrei Lepikhov and Petr Petrov modifies the executor directly:
- Planning phase: In
create_nestloop_path(), detect RestrictInfo clauses whose required relids only reference the outer side. Store these separately asrhs_joinrinfo. - Plan creation: In
make_nestloop(), remove these clauses fromjoinqualand store them in a newrhs_joinqualfield. - Execution: In
ExecNestLoop(), evaluaterhs_joinqualafter fetching the outer tuple but before fetching any inner tuple. If false, immediately emit the outer row with NULL inner columns, skipping the inner scan entirely. - EXPLAIN: Introduces "Outer Tuple Filter" as a new filter type with a
nunmatchedcounter inInstrumentation.
Tradeoffs: Touches multiple subsystems (planner, executor node, EXPLAIN). Adds new executor code paths specifically for outer joins in nestloop. Creates new node-level state that's only useful in this specific scenario.
Approach 2: Gating Result Node (Andres Freund's Suggestion, Lepikhov's 2026 Patch)
Instead of modifying the nestloop executor, inject a Result node with a One-Time Filter above the inner side of the parameterized nested loop:
Nested Loop Left Join
-> Seq Scan on products p
-> Result
One-Time Filter: (p.type = 'v'::text)
-> Index Scan using vehicles_pkey on vehicles v
Index Cond: (id = p.id)
The Result node evaluates the outer-only predicate first. When false, it returns no rows without ever invoking the child Index Scan. This leverages existing executor machinery — Result nodes with one-time filters are already implemented.
Tradeoffs:
- Pro: No new executor code paths. Reuses existing, well-tested Result node semantics.
- Pro: Less invasive overall — purely a planner transformation.
- Con: More complex planner patch to correctly identify and extract these clauses, then inject the Result node.
- Open question: Should this be modeled as a new Path type (GatingPath) to allow cost-based selection, or done as a plan-time transformation?
Key Design Decisions and Disagreements
Applicability to Other Join Types
Lepikhov initially proposed handling this for Hash Join and Merge Join as well. This was firmly rejected by both Andres and Tom Lane:
- Merge Join: Both sides are scanned once in sorted order. You can't "skip" scanning the inner side for particular outer rows without breaking the merge logic. The outer pointer must advance regardless.
- Hash Join: The hash table is built once. Looking up a key has trivial cost compared to the overhead of evaluating an additional expression. The win is negligible.
- Nested Loop: Each outer row triggers a fresh inner scan (often an index scan). Skipping that scan entirely is a substantial win proportional to the cost of the inner subtree.
Cost Model Implications
The 2026 patch from Lepikhov raises whether this should be integrated into the cost model via a new Path node. Currently, the Result node injection happens after path selection. If done at the Path level, the optimizer could cost-compare "gated nested loop" vs. other join strategies. Lepikhov prefers the simpler post-hoc approach unless evidence shows the Path-level change benefits other optimizations.
GUC Control
Lepikhov explicitly stated no GUC should be needed — the optimization should always be beneficial when applicable, as the Result node evaluation cost is negligible compared to an unnecessary index scan.
Architectural Implications
The "gating Result node" approach is architecturally elegant because it composes existing plan node semantics rather than adding special-case logic. It's analogous to how PostgreSQL already uses Result nodes for:
- Constant qualification (
ResultwithOne-Time Filter: falseto skip entire subtrees) - Projection-only nodes
- VALUES clauses
The key insight is that a parameterized inner path in a nested loop can be "gated" by predicates that only depend on parameters from the outer side. The Result node re-evaluates its One-Time Filter each time its parameters change (i.e., each new outer row), making it a natural fit for this per-outer-row gating behavior.
Current Status (as of May 2026)
Lepikhov's updated patch demonstrates the Result-node approach working correctly with significant scan reduction (from 10036 inner scans down to ~5000 in the benchmark case). Outstanding work includes:
- Fixing remaining issues
- Benchmarking to quantify overhead of the extra Result node for cases where the filter usually passes
- Writing regression tests
- Deciding whether Path-level integration is worthwhile