Nested Loop, Hash Join, Merge Join
Join nodes combine row sets from two inputs. PostgreSQL 18.4 picks nested loop, hash, or merge join based on row counts, available memory, index opportunities, and sort order.
Search across all documentation pages
Join nodes combine row sets from two inputs. PostgreSQL 18.4 picks nested loop, hash, or merge join based on row counts, available memory, index opportunities, and sort order.
Quick-reference recipe card - copy-paste ready.
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= now() - interval '1 day';When to reach for this: Join-heavy queries spike CPU or I/O. Identify which join algorithm dominates the plan.
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
CREATE INDEX orders_created_at_idx ON orders (created_at);
INSERT INTO customers (name)
SELECT 'customer-' || i FROM generate_series(1, 50000) AS i;
INSERT INTO orders (customer_id, created_at)
SELECT (random() * 49999 + 1)::bigint, now() - (random() * interval '30 days')
FROM generate_series(1, 500000);
ANALYZE customers;
ANALYZE orders;
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= now() - interval '1 day';What this demonstrates:
work_mem; spills to disk if exceeded (Hash Buckets / Batches in plan).| Algorithm | Favorable conditions |
|---|---|
| Nested Loop | Small outer cardinality; index on inner join key |
| Hash Join | Medium/large sets; equi-join; enough work_mem |
| Merge Join | Inputs already ordered; large sequential joins |
-- Reveal join order and type
EXPLAIN (VERBOSE, ANALYZE)
SELECT count(*)
FROM orders o
JOIN customers c ON c.id = o.customer_id;
-- Hash spill indicator
-- Look for: "Buckets: ... Batches: 2" in Hash Join node
SET work_mem = '64MB';Batches > 1 means work_mem too low for that node. Fix: Raise work_mem for the reporting role only, or reduce rows earlier with filters.ON/USING clauses in ORM-generated SQL.ORDER BY.ANALYZE without code changes. Fix: Capture plans in CI; alert on regressions.| Alternative | Use When | Don't Use When |
|---|---|---|
| Denormalize hot join keys | Read-heavy dashboard slice | Write-heavy normalized OLTP |
| Materialized view | Expensive join reused hourly | Real-time consistency required |
| LATERAL subquery | Top-N per group pattern | Simple equi-join of two big tables |
Depends on cardinality and indexes. There is no universal winner; read the plan for your data size.
PostgreSQL 14+ caches inner nested-loop results for repeated outer keys. Great for nested loop with stable inner parameters.
SET enable_nestloop = off in a test session only. Fix statistics and indexes instead for production.
Planner adds Sort nodes on both inputs. Check sort cost; an index may remove sorts.
Yes. PostgreSQL considers reordering via genetic optimizer on many tables. Bad estimates yield bad order.
EXISTS, IN, and NOT EXISTS often become hash semi-join or nested loop semi-join nodes.
Large builds may use Parallel Hash Join in PostgreSQL 18. Requires max_parallel_workers_per_gather > 0 and big inputs.
They do not auto-index. You still need indexes on child join columns for nested loop performance.
Nested loop with inner seq scan and row counts multiplying toward result size squared.
Sort and hash aggregates page for memory spill patterns shared with hash joins.
work_mem spillsStack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.
Reviewed by Chris St. John·Last updated Jul 18, 2026