When Merge Join Beats Hash Join #
A hash join is the default answer for large equality joins, and usually the right one. But it has a hard requirement — the build side must fit in memory or it spills — and a hard limitation: it cannot produce sorted output or stop early. A merge join has neither property. When its inputs arrive already ordered, it holds almost nothing in memory, streams results, and can be stopped by a LIMIT.
This page identifies the three situations where the merge is the better shape and shows the plan evidence for each. The pair-wise comparison with nested loops is covered in merge join vs nested loop.
The Three Conditions #
1. Both inputs are already sorted on the join key. An index scan on the join column, or a GROUP BY that already ordered its output, supplies the ordering for free. The merge then costs a single interleaved pass — no build phase, no memory.
2. The hash build side would spill. A hash join with Batches: 32 writes and re-reads its build side, and its cost grows non-linearly. A merge over indexed inputs has predictable, mostly sequential I/O.
3. Sorted output is needed anyway. If the query ends in ORDER BY on the join key, a merge join delivers it, while a hash join forces a separate sort of the full result.
The counterweight is duplication. When many inner rows share a key, the merge join must mark its position and re-scan that group for each matching outer row, which is why high-cardinality keys such as identifiers favour merges and low-cardinality flags do not.
Annotated Evidence #
The same join, forced both ways on a workload where the hash side spills:
-- Hash join: the build side does not fit
Hash Join (actual time=18220.4..39810.1 rows=4120334 loops=1)
Hash Cond: (e.session_id = s.session_id)
-> Seq Scan on events e (actual rows=48000000 loops=1)
-> Hash (actual rows=12000000 loops=1)
Batches: 32 Memory Usage: 65536kB
Buffers: shared read=182400, temp read=214880 written=214880
Execution Time: 39810.1 ms
-- Merge join over two index scans: no build, no temp files
Merge Join (actual time=0.08..21402.6 rows=4120334 loops=1)
Merge Cond: (e.session_id = s.session_id)
-> Index Scan using events_session_idx on events e (actual rows=48000000 loops=1)
-> Index Scan using sessions_pkey on sessions s (actual rows=12000000 loops=1)
Buffers: shared hit=412088 read=98420
Execution Time: 21402.6 ms
Both indexes supply the ordering, so there are no Sort nodes and the join holds only the current key group. The temporary I/O disappears completely, which is what makes the merge 1.9× faster here despite touching more index pages.
The early-termination case is even more lopsided:
-- Same join with ORDER BY session_id LIMIT 100
-- Hash: must build the entire hash table before the first row can be emitted
Limit (actual time=39810.2..39810.4 rows=100 loops=1)
-> Sort (actual rows=4120334 loops=1) Sort Method: external merge Disk: 412880kB
Execution Time: 41204.8 ms
-- Merge: streams in key order, so the LIMIT stops the whole pipeline
Limit (actual time=0.09..0.31 rows=100 loops=1)
-> Merge Join (actual rows=100 loops=1)
Execution Time: 0.36 ms
Step-by-Step Comparison #
-
Check what ordering already exists. An index on the join column of either side is the precondition for a free merge:
SELECT indexrelid::regclass, pg_get_indexdef(indexrelid) FROM pg_index WHERE indrelid = 'events'::regclass; -
Look for spill evidence on the current hash plan.
Batches > 1plustemp writtenis the strongest argument for trying a merge. -
Force each method inside a transaction and measure:
BEGIN; SET LOCAL enable_hashjoin = off; -- see the merge plan EXPLAIN (ANALYZE, BUFFERS) SELECT … ; ROLLBACK; -
Watch for injected
Sortnodes. A merge plan containing two sorts of the full inputs is usually worse than the hash plan; the merge only wins when the ordering is supplied. -
Make the ordering free rather than forcing the join method:
CREATE INDEX CONCURRENTLY events_session_idx ON events (session_id); -
Check inner-side duplication before committing. Many rows per key means repeated mark-and-restore work:
SELECT count(*)::numeric / count(DISTINCT session_id) AS rows_per_key FROM sessions; -
Reset the diagnostic settings and confirm the planner now chooses the merge on its own. If it does not, the cost model still prefers hashing — investigate the estimates rather than pinning the method.
Before and After #
-- BEFORE: hash join, build side spills into 32 batches
Hash Join Batches: 32 temp read=214880 written=214880
Execution Time: 39810.1 ms
-- AFTER: index on events(session_id) makes both inputs ordered
Merge Join no Sort nodes, no temp figures
Execution Time: 21402.6 ms -- and 0.36 ms with ORDER BY … LIMIT 100
The metric that changed is temporary I/O, not CPU. That matters because temporary traffic is the part of the cost that degrades fastest under concurrency.
Common Pitfalls #
Forcing a merge with enable_hashjoin = off in production. The setting applies to every join in every subsequent statement on that connection. Diagnostic signal: EXPLAIN (SETTINGS) reporting it. Fix: use SET LOCAL for diagnosis, then fix the underlying cost inputs.
Accepting a merge plan that sorts both inputs. Two full sorts usually cost more than one hash build and can spill just as badly. Diagnostic signal: Sort nodes with Sort Method: external merge under the merge join. Fix: supply the ordering with an index, or stay with the hash.
Ignoring inner-side duplication. Mark-and-restore over large duplicate groups is quadratic in the group size. Diagnostic signal: a merge join much slower than its input scans suggest. Fix: check rows per key, and prefer hashing on low-cardinality join columns.
Assuming the merge avoids all memory pressure. Only pre-sorted inputs give constant memory; injected sorts consume work_mem. Diagnostic signal: Disk: figures on the sorts. Fix: index the join keys.
Frequently Asked Questions #
Why does the planner rarely pick a merge join? Because it normally has to sort both inputs, and two sorts cost more than one hash build. It becomes competitive only when the ordering is free — from indexes or a previous operator — or when the hash side would spill heavily.
Does a merge join need less memory?
With pre-sorted inputs, far less: it holds only the current key group from each side. If the plan has to add explicit sorts, those consume work_mem and can spill exactly like a hash table.
What is mark and restore? When several inner rows share a join key, the executor marks its position and re-scans that group for each matching outer row. Large duplicate groups make this costly, which is why merge joins suit high-cardinality keys.
Related #
- Merge Join vs Nested Loop — parent guide: the full join-method comparison
- Forcing a Nested Loop for Small Inner Tables — sibling: the third join strategy
- Execution Plan Fundamentals — section overview: how join costs are compared
- Diagnosing Hash Join Batch Spills — the failure that makes a merge attractive