Hash Right and Full Joins for Outer Joins #
You wrote customers LEFT JOIN orders, and the plan says Hash Right Join. It is not a bug and the results are correct: the planner has flipped the join’s sides so that it can hash the smaller relation. But the flip changes which side sits in memory, which side is scanned in full, and what happens when the hash table spills — so it is worth knowing how to read.
The basic build-and-probe mechanism is covered in hash join mechanics. Outer joins add one requirement: rows from the preserved side must appear even when nothing matches.
The Algorithmic Condition #
In a hash join, the inner input is hashed and the outer input probes it. For an inner join the choice of sides is free. For outer joins it is constrained by which side must be preserved:
Hash Left Join— the probe side (outer) is preserved. Each probe row that finds no match is emitted null-extended immediately. The hashed side is the nullable one.Hash Right Join— the hashed side (inner) is preserved. Probe rows are emitted on match; hash-table entries record whether they matched, and after all probing finishes the unmatched entries are emitted null-extended.Hash Full Join— both sides are preserved: unmatched probe rows are emitted during probing, and unmatched hash entries at the end.
So customers LEFT JOIN orders can be executed either as Hash Left Join (probe with customers, hash orders) or as Hash Right Join with the inputs swapped (probe with orders, hash customers). When customers is far smaller than orders, hashing customers is the cheaper option, and the plan shows the “right” variant of the join you wrote as “left”.
Anti-joins follow the same idea: Hash Anti Join hashes the other side and emits unmatched probe rows, while Hash Right Anti Join (PostgreSQL 16+) hashes the preserved side — the pattern in semi-join and anti-join plan shapes.
Full joins have a restriction worth knowing: they need a hashable or mergeable equality condition covering the join. A FULL JOIN whose condition includes non-equality predicates may have no executable plan at all, and the planner reports that the join is only supported with merge-joinable or hash-joinable conditions.
Annotated EXPLAIN Evidence #
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.id, c.name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE c.region = 'nordics';
Hash Right Join (actual time=412.8..8840.2 rows=611204 loops=1)
Hash Cond: (o.customer_id = c.id)
-> Seq Scan on orders o (actual rows=94022110 loops=1) -- probe side: all orders
-> Hash (actual time=412.2..412.2 rows=40210 loops=1) -- hashed: filtered customers
Buckets: 65536 Batches: 1 Memory Usage: 4212kB
-> Seq Scan on customers c (actual rows=40210 loops=1)
Filter: (region = 'nordics'::text)
Execution Time: 8902.1 ms
-- the written LEFT JOIN runs as a RIGHT join: customers (preserved) are in the hash
-- unmatched customers are emitted after every order row has probed
Here the choice is correct for memory — 4 MB instead of hashing 94 million orders — but it forces a full scan of orders, because every order must probe to find out which customers matched. When an index on orders.customer_id exists and the customer set is small, a nested loop left join may be far faster:
Nested Loop Left Join (actual time=0.06..1210.4 rows=611204 loops=1)
-> Seq Scan on customers c (actual rows=40210 loops=1)
Filter: (region = 'nordics'::text)
-> Index Scan using orders_customer_id_idx on orders o (actual rows=15 loops=40210)
Index Cond: (customer_id = c.id)
Execution Time: 1302.7 ms
Step-by-Step Resolution #
-
Map the node to the SQL. Identify which relation is preserved in your query and which input of the hash join it is.
Rightin the node name means the preserved relation is the hashed input. -
Check the probe side’s size. A right-variant join always scans its whole probe input. If that input is huge and the preserved side small, an index on the nullable side’s join key may enable a better nested loop.
CREATE INDEX CONCURRENTLY orders_customer_id_idx ON orders (customer_id); ANALYZE orders; -
Verify the estimate on the preserved side. The planner picks nested loop versus hash from the estimated number of preserved rows; if
customersafter filtering is misestimated, the choice follows the error. -
For FULL JOIN errors, rewrite the join condition as a pure equality and move other predicates into
WHEREor into subqueries on each side, or express the full join as aUNION ALLof a left join and an anti-join. -
Watch memory under skew on the preserved side, since unmatched-entry tracking keeps the whole table in memory until probing completes; batching follows the rules in diagnosing hash join batch spills.
Before and After #
-- BEFORE: no index on orders.customer_id
Hash Right Join → Seq Scan on orders (94M rows) Execution Time: 8902.1 ms
-- AFTER: index on orders(customer_id)
Nested Loop Left Join → Index Scan using orders_customer_id_idx (loops=40210) Execution Time: 1302.7 ms
Why the planner does not always pick the nested loop #
With 40,000 preserved customers and 15 orders each, the nested loop performs 40,000 index descents and 611,000 heap fetches. If the region filter matched 4 million customers instead, that becomes 4 million descents and 60 million random heap fetches, and the hash right join over a sequential scan is the better plan. The planner’s choice is correct relative to its estimates; the useful question is whether those estimates match the distribution of values you actually query. Test with the largest realistic preserved set, not only the typical one, before forcing a shape with planner settings — the trade-offs of that are in forcing a nested loop for small inner tables.
Common Pitfalls #
Reading Hash Right Join as a wrong result. The planner may swap sides for any outer join. Diagnostic signal: node name contradicts the written join direction. Fix: map the preserved relation to the hashed input; results are unaffected.
Missing the full probe scan. A right-variant join must read all of its probe input. Diagnostic signal: a huge sequential scan under a join returning a small preserved set. Fix: index the nullable side’s join key.
Adding non-equality conditions to FULL JOIN. They can leave no executable join method. Diagnostic signal: the error about merge-joinable or hash-joinable conditions. Fix: keep only equality in the ON clause.
Filtering the nullable side in WHERE. WHERE o.status = 'paid' removes null-extended rows and silently turns the left join into an inner join — which the planner does recognise, changing the plan shape. Diagnostic signal: Hash Join where you expected an outer join. Fix: put the condition in ON if unmatched customers should remain.
Frequently Asked Questions #
Why does my LEFT JOIN show as Hash Right Join? #
The planner swapped the join’s inputs so that the smaller, preserved relation is hashed. A Hash Right Join keeps unmatched hash entries and emits them after probing, which produces exactly the rows your LEFT JOIN specifies.
Can PostgreSQL execute FULL OUTER JOIN as a hash join? #
Yes, as Hash Full Join, when the join condition is a hashable equality. Full joins with non-equality conditions in the ON clause may have no valid join method and raise an error.
Is a Hash Right Join slower than a Hash Left Join? #
Not inherently. It must scan its entire probe input and track matches in the hash table, but it hashes the smaller preserved side. Whether it is faster depends on the relative sizes and on whether an index enables a nested loop instead.
Related #
- Hash Join Mechanics — parent guide: build and probe phases
- Hash Join Skew and Hot-Key Batches — sibling: when the hashed side cannot be split
- Semi-Join and Anti-Join Plan Shapes — the related right-anti variants