PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 15: Reading plans

Join algorithms and join order

Level 4: Performance25 min

PostgreSQL joins two inputs with one of three algorithms: nested loop, hash join or merge join. Each wins in a different situation, and the choice depends almost entirely on row estimates.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Explain how nested loop, hash join and merge join work and their costs
  • Recognise each in a plan and know what makes the planner pick it
  • Spot the misestimate that turns a nested loop into a disaster

conceptThe concept

Nested Loop: for each outer row, look up matches in the inner input, ideally through an index. Unbeatable when the outer side is small; catastrophic when the planner thinks it is small and it is not.

Hash Join: build a hash table from the smaller input, then probe it with each row of the larger one. Good for large, unsorted inputs joined on equality. Memory is limited by work_mem times hash_mem_multiplier; beyond that it splits into batches on disk (Batches: 4 in the plan).

Merge Join: walk two inputs sorted on the join key side by side. Good when both sides are already sorted (by an index) or the data is huge. With several tables, the planner also chooses the join order; above join_collapse_limit (8) tables it stops searching all orders, and above geqo_threshold (12) it uses a genetic search.

whyWhy it matters

The most common bad plan in production is a nested loop chosen because the planner estimated 1 outer row when there were 50,000. Recognising that pattern lets you fix the estimate rather than fighting the planner with settings.

Use caseReal-world scenario

A report that joins orders to customers for the whole year uses a hash join; the order details page that fetches 20 orders and their items uses nested loops over primary-key indexes. Both are right for their shapes.

Try itExamples

Run and edit these in the interactive lesson.

Nested loop for a small outer side
EXPLAIN
SELECT o.id, oi.line_no, oi.product_id
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.id BETWEEN 100 AND 120;
Hash join for whole-table work
EXPLAIN (ANALYZE, COSTS OFF)
SELECT c.country, sum(o.total)
FROM orders o
JOIN customers c ON c.id = o.customer_id
GROUP BY c.country;
The smaller input (customers) is hashed; the Hash node reports buckets, batches and memory used.

Disabling the other algorithms for one transaction shows the plan the planner rejected and its higher cost. Never ship such settings globally.

Merge join, forced for comparison
BEGIN;
SET LOCAL enable_hashjoin = off;
SET LOCAL enable_nestloop = off;
EXPLAIN
SELECT o.id, c.name FROM orders o JOIN customers c ON c.id = o.customer_id;
ROLLBACK;
Hash join with too little memory
BEGIN;
SET LOCAL work_mem = '64kB';
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM order_items oi JOIN orders o ON o.id = oi.order_id;
ROLLBACK;
Batches above 1 means the hash table did not fit in memory and spilled to temporary files.

Avoid / preferDo this, not that

Avoid
ALTER SYSTEM SET enable_nestloop = off;
Prefer
-- find the misestimate that made the nested loop look cheap:
-- ANALYZE, extended statistics, or a query rewrite

Disabling a join method globally fixes one query and breaks many others. The enable_* settings are diagnostic tools, not tuning knobs.

Avoid
SELECT * FROM events e JOIN customers c ON c.id::text = e.payload ->> 'customer';
Prefer
SELECT * FROM events e JOIN customers c ON c.id = (e.payload ->> 'customer')::int;

Casting the indexed side to text prevents the index on customers.id from being used. Cast the other side.

PitfallsCommon mistakes

  • Turning off nested loops globally because one query had a bad one. Fix the estimate (statistics, rewrite) for that query instead.
  • Assuming merge joins are bad because of the Sort. When both sides come from indexes in order, a merge join needs no sort at all.
  • Raising work_mem globally to fix one spilling hash join. It applies per sort or hash node, per connection; set it per session or role.

LimitsWhen not to use it

Do not pin join order with join_collapse_limit = 1 unless you have a stable query whose planner choice you have proven wrong and cannot fix with statistics.

SecuritySecurity notes

enable_* settings and work_mem are user-settable, so any role can make its own queries slow or memory-hungry. Set sane per-role defaults with ALTER ROLE ... SET and watch pg_stat_statements.

ChallengeTry it yourself

Find which join algorithm the planner chooses to join all orders to their customers, using pgm.plan_nodes.

Show one solution
One solution
SELECT pgm.plan_nodes('SELECT * FROM orders o JOIN customers c ON c.id = o.customer_id') AS nodes;

InterviewQuestions you should be able to answer

Describe PostgreSQL's three join algorithms and when each is chosen.
Nested loop: for each outer row, probe the inner side (usually an index). Best when the outer side is small. Hash join: build a hash table on the smaller input, probe with the larger; best for large equality joins on unsorted data, limited by work_mem times hash_mem_multiplier before batching to disk. Merge join: both inputs sorted on the key and merged; best when inputs are already sorted by indexes or when very large. The choice depends on estimated row counts and available indexes.
A query that usually takes 50 ms sometimes takes minutes. What is a likely plan-level cause?
A nested loop chosen on a low row estimate that is sometimes badly wrong, for example because of skewed data (one huge customer), correlated predicates, stale statistics or a generic plan for a prepared statement. Check EXPLAIN ANALYZE for estimated versus actual rows on the outer side of the loop, then fix statistics (ANALYZE, higher statistics target, CREATE STATISTICS) or the query shape, and consider plan_cache_mode for prepared statements.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation