Cloud Tech by Victor
DatabasesIntermediate

SQL Joins & Query Planning

Why the same JOIN can execute as a nested loop, a hash join, or a merge join depending entirely on table size and available sort order, and why "it's impossible to suppress nested loops entirely" is a real, documented planner constraint.

Updated 2026-07-243 min read

Overview

The same JOIN in SQL can compile to genuinely different execution strategies depending on table size, available indexes, and existing sort order: a nested loop scans the inner table once per outer row, cheap when the inner side is small or indexed; a hash join builds an in-memory hash table from one side and probes it with the other, efficient for large, unindexed inputs; a merge join walks two already-sorted inputs in lockstep, efficient specifically when that sort order already exists. PostgreSQL's planner picks between these automatically based on cost estimates derived from table statistics, and the configuration knobs that discourage a given method (enable_nestloop, etc.) are explicitly documented as unable to suppress a method entirely, some query shapes have no other viable plan. That's why the real fix for a bad plan is almost always better statistics or a better index, not forcing a specific join algorithm.

Quick Reference

Join algorithmHow it worksFavored when
Nested loopScan inner table once per outer rowInner side is small or indexed
Hash joinBuild hash table from one side, probe with the otherLarge inputs, no useful sort order
Merge joinWalk two sorted inputs in lockstepInputs already sorted (or sorting helps elsewhere)

Syntax

sql
EXPLAIN ANALYZE
SELECT o.id, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > now() - interval '7 days';

Examples

sql
-- Reading EXPLAIN ANALYZE output - the chosen join type is
-- right there in the plan, not something you have to guess.
Hash Join  (cost=1.09..458.32 rows=1200 width=64)
  Hash Cond: (o.customer_id = c.id)
  ->  Seq Scan on orders o
  ->  Hash  (cost=1.05..1.05 rows=200 width=36)
        ->  Seq Scan on customers c

Planner method flags are for diagnosis, not production tuning

Only enable_nestloop carries the documented guarantee that it can't be suppressed entirely, some query shapes have no other viable plan. enable_hashjoin and enable_mergejoin can actually prevent their join type from being chosen. Use these settings to understand why the planner chose a plan, then fix the real cause (statistics, indexes), don't leave them disabled in production.

Visual Diagram

Common Mistakes

  • Assuming a JOIN always executes the same way regardless of table size or indexes, when the planner picks a genuinely different algorithm based on both.
  • Disabling a join method (enable_hashjoin = off) in production to force a different plan, instead of treating it as a temporary diagnostic step.
  • Not running EXPLAIN ANALYZE before assuming why a query is slow, guessing at the join algorithm instead of reading it directly from the plan.
  • Letting table statistics go stale (never running ANALYZE after large data changes) and then being confused when the planner picks a plan that made sense for the old data distribution.

Performance

  • A mismatched join algorithm for the actual data shape (a nested loop over two large, unindexed tables) can be orders of magnitude slower than the plan the planner would pick with accurate statistics and the right index.
  • EXPLAIN ANALYZE reports both the estimated and actual row counts per plan node, a large gap between them is a direct signal that statistics are stale and the planner's cost estimates, and therefore its choices, can't be trusted until ANALYZE is re-run.

Best Practices

  • Read EXPLAIN ANALYZE output directly rather than guessing which join algorithm a query used.
  • Keep table statistics current with ANALYZE, especially after large bulk changes, since the planner's join choice depends entirely on accurate row-count and cardinality estimates.
  • Add or adjust indexes to make a nested loop's inner-side lookup cheap, rather than fighting the planner's algorithm choice directly.
  • Treat enable_* planner method flags as a temporary diagnostic tool, not a production configuration to leave permanently changed.

Interview questions

What are the three join algorithms PostgreSQL's planner can choose between, and what does each one actually do?

A nested loop join takes each row from one table (the outer side) and scans the other table (the inner side) for matches, the simplest algorithm, and cheap specifically when the inner side is small or has a usable index so that scan is fast per outer row. A hash join builds an in-memory hash table from one input keyed on the join column, then probes it with rows from the other input, efficient for large inputs with no useful sort order or index. A merge join requires both inputs already sorted on the join key (or sorts them first) and then walks both sorted streams in lockstep, efficient specifically when that sorted order already exists or is useful for something else in the query anyway.

Why would a hash join beat a nested loop join for two large, unindexed tables, but lose to a nested loop join when one table is tiny?

A nested loop join's cost scales with (outer rows) × (cost of an inner-side lookup per row), so with two large unindexed tables, the inner-side scan is expensive and gets repeated for every single outer row, making the total cost grow multiplicatively. A hash join instead pays a roughly one-time cost to build a hash table from one side, then does a cheap lookup per row from the other side, additive rather than multiplicative, which wins decisively at scale. But when one table is tiny, the nested loop's "repeat the inner scan per outer row" cost is trivial regardless, and it avoids the hash table's build overhead entirely, so the simpler algorithm wins for small inputs specifically.

PostgreSQL's documentation says it's "impossible to suppress nested-loop joins entirely" even with enable_nestloop off. What does that tell you about relying on planner hints to force a specific join algorithm?

That specific guarantee is documented only for `enable_nestloop`: turning it off only discourages the planner by making nested loops look artificially expensive in cost estimation, it can't hard-disable them, because for some queries a nested loop is the only viable plan at all (for example, certain correlated subquery shapes), so the planner will still use one if it must. `enable_hashjoin` and `enable_mergejoin` don't carry that same caveat, disabling either one actually can prevent the planner from choosing that join type, since a nested loop (or the other remaining method) is always available as a fallback plan. In practice, though, all three settings are best treated as a debugging/diagnostic tool for understanding planner behavior, not a reliable production mechanism for forcing a specific join algorithm, the actual fix for a bad plan is almost always better statistics (via `ANALYZE`) or a better index, not overriding the planner's method choice.

References

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement