CMD Guide
HomeDatabasesQuery Execution

Join Algorithms — Nested Loop, Hash & Sort-Merge

Three ways to combine two tables

A JOIN isn't one operation — the optimizer picks one of three algorithms based on table sizes, indexes, and whether sorted output is needed. Knowing them turns a mysterious slow join into an obvious "wrong algorithm / missing index" diagnosis.

Hash join builds a hash table on the small users table keyed by id, then probes it with each row of the large orders table
Hash join builds a hash table on the small users table keyed by id, then probes it with each row of the large orders table

The three algorithms

AlgorithmHow it worksCostChosen when
Nested loopfor each row in A, find matches in BO(A×B), or O(A×log B) with an index on BA is small and B has an index on the join key
Hash joinbuild a hash table on the smaller side's key, probe with the largerO(A+B), needs memorylarge equi-join, no useful index, order not needed
Sort-merge joinsort both by the join key, then merge in one passO(A log A + B log B), or O(A+B) if pre-sortedinputs already sorted (an index), or sorted output wanted

The trap to recognise in EXPLAIN

A Nested Loop over two large tables with no index on the join key is the classic catastrophe — millions × millions. The fix is almost always: add an index on the join column, or let the planner pick a hash join (check your stats are fresh).
EXPLAIN ANALYZE SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
--  Hash Join  (hash table on users; probe with orders)          <- good for big o, small u
--  vs  Nested Loop -> Index Scan on users_pkey                   <- good when o is small
--  vs  Merge Join  (both already ordered by the join key)

The Sort-Merge Join Duplicate Key Trap (Backtracking)

A standard sort-merge join operates in $O(N \log N + M \log M)$ to sort, followed by a linear $O(N + M)$ pass using two cursors to merge the rows. However, a naive two-cursor linear pass fails when duplicate keys exist on both sides.

🔄 Worked Trace — Merge Join with Duplicates:
Imagine joining two sorted lists on their key values:
  • List A: [1, 2, 2, 3] (cursor ptr_A)
  • List B: [2, 2, 4] (cursor ptr_B)
  1. ptr_A points to 1, ptr_B points to 2. Since $1 < 2$, we advance ptr_A.
  2. ptr_A points to the first 2, ptr_B points to the first 2. We have a match! We output the joined row.
  3. The Trap: If we simply advance one cursor (say, ptr_B to the second 2), we match the first 2 of A with the second 2 of B. But if we then advance ptr_A to the second 2, ptr_B is already past the first 2 of B, and we miss the match between the second 2 of A and the first 2 of B.
  4. The Backtracking Solution: The database engine sets a mark_B pointer at the start of the matching run on the right side (the first 2 in B). It advances ptr_B to output all matches for the first 2 of A. When ptr_B reaches the end of the duplicates (value 4), ptr_A is incremented to the second 2, and ptr_B backtracks to the mark_B position to scan the duplicates again.
Complexity Note: If a join key is duplicated $X$ times in A and $Y$ times in B, the algorithm performs $X \times Y$ local comparisons. If the join key has extremely high cardinality of duplicates, the merge phase degrades from linear $O(N+M)$ to local quadratic $O(N \times M)$ complexity.

Equality vs theta: who can run which algorithm

Hash join and sort-merge join require an equality (equi-join) predicate on the join keys: ON a.k = b.k (or a conjunction of equalities). They hash or sort on those keys. A theta join uses a non-equality comparison — ON a.score > b.threshold, ON a.ts BETWEEN b.start AND b.end, band joins, etc. Those force nested loop (possibly with an index range scan on the inner side if one exists). Seeing Nested Loop on a huge join is not always a bug: if the predicate is not equi, the planner has no hash/merge option.

-- Equi-join: hash or merge eligible
SELECT * FROM orders o JOIN users u ON o.user_id = u.id;

-- Theta / non-equi: nested loop only (index on inner may still help)
SELECT * FROM events e JOIN windows w
  ON e.ts >= w.start_ts AND e.ts < w.end_ts;

Hash spill mechanism: grace / hybrid hash join

"Spills to disk" is not a single sequential dump. When the build side does not fit in work_mem, engines use a partitioned (grace / hybrid) hash join:

  1. Partition pass. Hash each build row by the join key into P partitions (batches) written to temp files so each partition is aimed to fit in memory. Probe-side rows are partitioned with the same hash function into matching files.
  2. Per-partition join. For each partition i: load build partition i into an in-memory hash table; scan probe partition i and probe. Output matching pairs. Discard the table; move to i+1.
  3. Recursive spill. If a single partition still exceeds memory (skew: one popular key hashes alone into a huge bucket), the engine re-partitions that partition with another hash (or falls back to nested loop for that slice). Heavy skew is why "raise work_mem" alone sometimes fails.

In Postgres EXPLAIN ANALYZE, Batches > 1 (and growing Batches mid-execution) signals this path. Cost becomes multi-pass disk I/O proportional to data size × number of partition rounds — far more than an in-memory O(A+B) hash join.

-- Healthy: Batches=1 (fits in work_mem)
-- Hash  (Batches=1  Memory Usage=8192kB)
-- Unhealthy spill:
-- Hash  (Batches=32  Memory Usage=4096kB)  -- 32 partition pairs hit disk

When-NOT summary

Pitfalls

Takeaways


Re-authored for this guide; hash-join diagram hand-authored as SVG. Follows CMU 15-445 and DDIA ch. 3. See also: How a Query Executes, How Indexes Work.

🤖 Don't fully get this? Learn it with Claude

Stuck on Join Algorithms — Nested Loop, Hash & Sort-Merge? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Join Algorithms — Nested Loop, Hash & Sort-Merge** (Databases) and want to truly understand it. Explain Join Algorithms — Nested Loop, Hash & Sort-Merge from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Join Algorithms — Nested Loop, Hash & Sort-Merge** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Join Algorithms — Nested Loop, Hash & Sort-Merge** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Join Algorithms — Nested Loop, Hash & Sort-Merge** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes