← databases book ⊞ All topics

Join Algorithms — Nested Loop, Hash Join & Merge Join

JOIN is a word in SQL. It names a result — which pairs of rows from two tables belong together. It says nothing about how to find them. Every relational database answers that question with one of exactly three physical algorithms — nested loop, hash join, and merge join — and the query planner's job is to pick the cheapest one for the data actually in front of it.

This is where the declarative half of SQL meets the physical half of the storage engine. B-tree indexes, the buffer pool, and heap pages matter here because the join algorithm decides which of those capabilities a given query actually uses — and it is the layer EXPLAIN reports on.

Key Components

Outer relation
The relation that drives the loop or the probe scan. It is scanned once, and it appears first — the upper node — in EXPLAIN output.
Inner relation
The relation searched for each outer row. A nested loop rescans it every time; a hash or merge join scans it exactly once instead.
Build phase / Probe phase
The two steps of a hash join. Build loads the smaller relation into an in-memory hash table on the join key. Probe scans the larger relation and looks up each row's key in that table.
Equijoin
A join condition that tests equality (=) on the join key. A hash table can only answer "which rows have exactly this key?", so hash join can only implement an equijoin — never a range condition.
Batches
The number of times a hash join must partition and reread its inputs when the build side does not fit into work_mem × hash_mem_multiplier (the hash table's memory budget). Batches: 1 is healthy; a higher count means it spilled to disk.

Concrete Example

One query, three physical strategies. Take a recurring join: transactions to merchants on merchant_id. The logical result is identical under all three plans below — only the method of finding matches changes.

Nested Loop
  ->  Seq Scan on merchants m                          (N = 40 rows)
  ->  Index Scan using idx_txn_merchant on transactions t
        Index Cond: (merchant_id = m.merchant_id)

The planner drives the loop from the small side — 40 merchant rows — and looks each one up through an index on the join key: a B-tree descent, not a table scan. That turns the naive O(N × M) cost into O(N × log M).

Hash Join
  Hash Cond: (t.merchant_id = m.merchant_id)
  ->  Seq Scan on transactions t          -- probe side (large)
  ->  Hash
        ->  Seq Scan on merchants m       -- build side (small)

Postgres always builds the in-memory hash table on the smaller side — here, merchants — then scans transactions once, hashing each row's key and looking it up. Each side is read exactly once: O(N + M).

Merge Join
  ->  Index Scan using merchants_pkey on merchants     -- free ordering
  ->  Materialize
        ->  Sort
              Sort Key: t.merchant_id
              ->  Seq Scan on transactions t

Merchants already arrives in merchant_id order for free, straight off its primary-key index. Transactions does not, so the planner pays for an explicit Sort first. Once both sides are ordered, two cursors walk them in a single pass.

Visual Model

Computer science has three standard answers to any lookup problem: scan a list, build a hash map, or merge two sorted lists. A database engine uses all three. None is "the fast one" — each wins in a regime the others lose, and which one wins depends on the shape of the data, not any inherent superiority.

Pick an algorithm below and step through it. The same two small tables — merchants (101, 105, 108, 112) and transactions (101, 105, 105, 108, 112, 999) — run through all three, so the difference in mechanism is the only thing that changes.

Step 1 of N
Nested Loop — outer scanned once, inner rescanned per outer row OUTER — merchants (driving relation, scanned once) 101 105 108 112 … 36 more (N = 40) INNER — transactions (no index: full scan every outer row) 101 105 105 108 112 999 … 3,114 more (M = 3,120) Indexed alternative — B-tree on transactions.merchant_id root ≤ 105 > 105 match: 101 match: 108

Loading…

The decision table

This is the part worth being able to reproduce cold.

Nested LoopHash JoinMerge Join
CostO(N × M), or O(N × log M) with an inner indexO(N + M)O(N log N + M log M), or O(N + M) if pre-sorted
Needs an index?To be good, yes — on the inner join keyNoHelps enormously — removes the sort
MemoryNegligibleBuild side must fit in work_mem × hash_mem_multiplierSort needs work_mem, spills gracefully
Equality only?No — any conditionYesNo
Best whenOuter side is small, inner is indexedOne side is small enough to hash; no useful indexesBoth sides large and already sorted
Worst whenOuter is large or inner is unindexedBuild side vastly exceeds memoryNeither side is sorted and both are huge
Rows arriveImmediately (streams)Only after the build completesAfter sorting completes

A nested loop can return its first row almost instantly. A hash join must finish building before it emits anything. This is why the planner's choice can change under a LIMIT — a cheap-startup plan becomes attractive when only the first few rows are needed.

Deeper — Edge Cases & Gotchas

Materialize and Memoize — caching under a nested loop

Two plan nodes appear under nested loops to make the rescanning cheaper. Materialize caches the inner relation's rows once, so later iterations read from memory instead of re-running the scan. Memoize (Postgres 14 and later) caches results per parameter value, so a repeated outer value skips the inner scan entirely.

Memoize is the planner applying the same fix an application developer would apply to an N+1 problem: remember the answer for a repeated key. It pays off when outer values repeat often. The nested loop above is structurally identical to the application-level N+1 antipattern — fetch a list, then issue one lookup per element. The difference is entirely about where the loop runs.

Inside the engine (nested loop)In application code (N+1)
Per-iteration costA B-tree descent, pages likely already cachedA full network round trip, parse, and plan
Typical costMicrosecondsAbout 1 ms or more
Planner can see it?Yes — it costs the loop and may reject itNo — each query looks independent

Same loop, three orders of magnitude apart. That gap is why "push the join into the database" is advice, not dogma.

The row-estimate cliff

The planner's algorithm choice is only as good as its estimate of how many rows each side produces. Get that wrong, and the choice inverts from optimal to catastrophic.

Anti-pattern: treating a Hash Join → Nested Loop plan flip as itself the bug, and "fixing" it by suppressing nested loops.
Nested Loop  (cost=... rows=5 ...) (actual time=... rows=500000 ...)
                          ^^^^^^                          ^^^^^^^^^^
                        estimate                            reality

The plan was correct for the statistics it had. It was wrong about the statistics. A wrongly-chosen nested loop degrades without bound, because the row-count error multiplies directly into the iteration count. A wrongly-chosen hash join merely spills to disk — bad, but bounded. That asymmetry is why Hash Join → Nested Loop deserves alarm that the reverse flip does not, and why a surprise nested loop over a large outer relation is the most common cause of a query that used to take 50 ms and now takes an hour.

Likely causes, in order: stale statistics after a bulk load (ANALYZE has not caught up); a cost threshold crossed gradually as data grew, tipping the plan with nothing "broken"; and correlated columns, where the planner assumes independence and multiplies two selectivities that should not be multiplied, producing an estimate far too small. The general principle: when a plan goes wrong, the plan is rarely the bug. Trace back to the estimate that produced it.

Batches > 1 has two causes, not one

A spilling hash join is a signal to investigate, not a diagnosis. It has two distinct causes with opposite fixes:

  1. work_mem is genuinely too small for the workload.
  2. The row estimate was wrong. The planner sized the hash table for the rows it expected; if it expected 10,000 and 4 million arrived, the table overflows regardless of how reasonable work_mem is.

Check the estimated-versus-actual row count on the build side before changing any configuration — otherwise memory gets tuned forever on what is really a statistics problem. And note that work_mem is granted per operation, per session, not per transaction: one query with three hash joins and two sorts can hold five separate grants at once, so the real multiplier is connections × operations-per-query.

Capability limit, not fallback

A hash table can only answer "which rows have exactly this key?" It cannot answer "which rows have a key less than this one?" A join condition using <, >, BETWEEN, or a range overlap is not hashable, so nested loop and merge join are the only options for a range join. When the planner picks one of them for a range condition, it has not degraded to a fallback — a hash join was never a candidate.

Postgres makes this explicit for one join type. A FULL OUTER JOIN has no cheap way, under a nested loop, to identify inner rows that never matched anything, so the condition must be hash-joinable or merge-joinable:

-- ERROR: FULL JOIN is only supported with merge-joinable or hash-joinable join conditions
FROM a FULL JOIN b ON a.x < b.y

An error that looks arbitrary becomes obvious once the capability limits of each algorithm are known.

What you actually control

The algorithm is not something to choose directly. It is a decision to shape by controlling the conditions the planner reasons about.

LeverEffect
Index the foreign key / join columnMakes the indexed nested loop and the sort-free merge join possible at all
Match the index to the join key orderThe leftmost prefix must cover the join key
Keep statistics fresh (ANALYZE)Statistics drive every cost estimate in this decision
Size work_mem for the workloadKeeps hash joins at Batches: 1
Filter early and reduce the row countA smaller outer side makes a nested loop viable
Anti-pattern: reaching for enable_nestloop = off (or enable_hashjoin / enable_mergejoin) as a permanent fix for a bad plan.
SET enable_nestloop = off;   -- "fixes" the one slow query...
-- ...and now discourages nested loops for every other query on this connection too.

These switches all default to on and are diagnostic tools, not tuning knobs. Setting one off and re-running EXPLAIN reveals the planner's second choice and its cost, which shows how confident the planner was — the suppression is not even absolute: "It is impossible to suppress nested-loop joins entirely, but turning this variable off discourages the planner from using one if there are other methods available." Never ship these in application configuration. If a plan is wrong, the fix is an index or better statistics.

A separate decision: join order and GEQO

The cost-based approach itself dates to Selinger et al.'s 1979 System R paper, which established the model every mainstream planner still uses: enumerate the plans, estimate a cost for each, pick the cheapest.

Choosing an algorithm for one join is not the whole problem. With N tables, the planner must also choose the order to combine them, and the number of possible orderings grows factorially. Below geqo_threshold (default 12 relations), Postgres runs a near-exhaustive search and prefers pairs of relations that share a WHERE join clause. Above the threshold, it switches to a genetic algorithm, and the search becomes heuristic.

Two consequences follow. Below the threshold, the order tables are written in does not matter — the planner reorders freely, so "smallest first" is folklore, not a rule. Above the threshold, planning becomes heuristic and non-deterministic, so a 15-way ORM-generated join can produce a different plan on different runs — the first thing to check when such a query has unstable performance.

Cost constants

The planner converts everything to one abstract unit, anchored on a sequential page read.

ConstantDefaultMeaning
seq_page_cost1.0The cost of a disk page fetch that is part of a sequential series.
random_page_cost4.0The cost of a non-sequential disk page fetch.
cpu_tuple_cost0.01The cost of processing each row during a query.
cpu_index_tuple_cost0.005The cost of processing each index entry during an index scan.
cpu_operator_cost0.0025The cost of processing each operator or function call.

The 4:1 ratio between random and sequential access encodes a spinning disk. On SSDs that penalty is too high; lowering random_page_cost to roughly 1.1 is the standard adjustment, and it makes index scans — and therefore indexed nested loops — look correctly cheap.

Reading it in EXPLAIN

Everything above surfaces as three node names and a handful of numbers.

Hash Join
  Hash Cond: (t.merchant_id = m.merchant_id)
  ->  Seq Scan on transactions t   -- probe side
  ->  Hash
        Buckets: 1024  Batches: 1  Memory Usage: 40kB    -- Batches: 1 = healthy
        ->  Seq Scan on merchants m                      -- build side
  1. Which of the three node names is it — Nested Loop, Hash Join, or Merge Join?
  2. Does the estimated rows= match the actual rows=? A large gap is the root cause of most bad joins.
  3. On a hash join, what does Batches: say? Above 1 means it spilled.
  4. Under a merge join, is there a Sort node or an Index Scan? A sort means an index could remove it.
  5. On a nested loop, is the inner side an Index Scan or a Seq Scan? A sequential scan on the inner side of a nested loop over a large outer relation is an emergency signal.

Test Yourself

A join condition uses < instead of =. Which algorithms can execute it, and which never can?

EXPLAIN ANALYZE shows a Hash node with Batches: 16. What does that tell you?

A query that ran in 50 ms for months now takes 40 minutes, and its plan flipped from Hash Join to Nested Loop. What would you check first, and why does this direction of flip deserve more concern than the reverse?