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
EXPLAINoutput. - 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: 1is 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.
Loading…
Loading…
Loading…
The decision table
This is the part worth being able to reproduce cold.
| Nested Loop | Hash Join | Merge Join | |
|---|---|---|---|
| Cost | O(N × M), or O(N × log M) with an inner index | O(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 key | No | Helps enormously — removes the sort |
| Memory | Negligible | Build side must fit in work_mem × hash_mem_multiplier | Sort needs work_mem, spills gracefully |
| Equality only? | No — any condition | Yes | No |
| Best when | Outer side is small, inner is indexed | One side is small enough to hash; no useful indexes | Both sides large and already sorted |
| Worst when | Outer is large or inner is unindexed | Build side vastly exceeds memory | Neither side is sorted and both are huge |
| Rows arrive | Immediately (streams) | Only after the build completes | After 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 cost | A B-tree descent, pages likely already cached | A full network round trip, parse, and plan |
| Typical cost | Microseconds | About 1 ms or more |
| Planner can see it? | Yes — it costs the loop and may reject it | No — 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.
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:
work_memis genuinely too small for the workload.- 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_memis.
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.
| Lever | Effect |
|---|---|
| Index the foreign key / join column | Makes the indexed nested loop and the sort-free merge join possible at all |
| Match the index to the join key order | The leftmost prefix must cover the join key |
Keep statistics fresh (ANALYZE) | Statistics drive every cost estimate in this decision |
Size work_mem for the workload | Keeps hash joins at Batches: 1 |
| Filter early and reduce the row count | A smaller outer side makes a nested loop viable |
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.
| Constant | Default | Meaning |
|---|---|---|
seq_page_cost | 1.0 | The cost of a disk page fetch that is part of a sequential series. |
random_page_cost | 4.0 | The cost of a non-sequential disk page fetch. |
cpu_tuple_cost | 0.01 | The cost of processing each row during a query. |
cpu_index_tuple_cost | 0.005 | The cost of processing each index entry during an index scan. |
cpu_operator_cost | 0.0025 | The 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
- Which of the three node names is it —
Nested Loop,Hash Join, orMerge Join? - Does the estimated
rows=match the actualrows=? A large gap is the root cause of most bad joins. - On a hash join, what does
Batches:say? Above 1 means it spilled. - Under a merge join, is there a
Sortnode or anIndex Scan? A sort means an index could remove it. - On a nested loop, is the inner side an
Index Scanor aSeq Scan? A sequential scan on the inner side of a nested loop over a large outer relation is an emergency signal.
See Also
SQL Fundamentals — Joins, Aggregations, Subqueries & CTEs The declarativeJOIN keyword this entry gives a physical implementation to — this is where the SQL half meets the engine half.
Window Functions — RANK, ROW_NUMBER, LAG/LEAD, Partitions & Frames
Both build on the same declarative-versus-physical split: window functions fix a row-level computation order, this entry fixes how matching rows are found.
B-tree Indexes — Structure & How Range/Equality Queries Use Them
The B-tree is what turns nested loop into O(N × log M) and what makes merge join's ordering free — its leaf level already is the sorted keys.
Composite Indexes — Column Order & the Leftmost-Prefix Rule
An index only speeds up the inner side of a nested loop if its leftmost prefix actually covers the join key.
Covering Indexes & Index-Only Scans
An index-only scan on the inner join key removes the heap fetch an indexed nested loop would otherwise pay for every row.
Buffer Pool / Page Cache — How a DB Manages Memory
The hash table lives in the executor's own working memory, not in shared_buffers — a genuinely separate allocation that is easy to conflate with this one.
How Data Is Stored on Disk — Heap Files, Pages & Slots
What a sequential scan actually reads page by page, on either side of any of these three algorithms.
Clustered vs Non-clustered Indexes
The same random-versus-sequential seek-cost story behind the 4:1 random_page_cost ratio that shapes every join-algorithm decision here.
Query Planning & Optimization — How the Database Picks a Plan
The System R cost-based search and the geqo_threshold join-ordering machinery that decides, one level up, which of these three algorithms even gets tried.
EXPLAIN & EXPLAIN ANALYZE — Reading Query Plans in Practice
Where the three node names and the rows= / Batches: diagnostics from this entry actually show up on a real plan.
Sources consulted
- PostgreSQL Documentation — 52.5. Planner/Optimizer — cited in source research
- PostgreSQL Documentation — 20.7. Query Planning (planner method config and cost constants) — cited in source research
- PostgreSQL Documentation — 20.4. Resource Consumption (work_mem, hash_mem_multiplier) — cited in source research
- PostgreSQL Documentation — 7.2. Table Expressions (join semantics) — cited in source research
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?