People talk about join types like they are personalities. Nested loop bad, hash join good, merge join mysterious. That is not how PostgreSQL performance works. Each join type is excellent when the inputs match its shape.
A slow join usually means PostgreSQL was given the wrong picture of the inputs or no cheap path to reach them. It thought the outer side was tiny. It thought a filter was selective. It thought an index lookup would run a few times and it ran a million.
The fix starts by asking why this join type was chosen, not by disabling join methods and hoping the next plan looks nicer.
Nested loops are multipliers
A nested loop is perfect when the outer side is small and the inner lookup is cheap. It becomes painful when a bad estimate makes the outer side much larger than expected.
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending'
AND o.created_at >= now() - interval '30 days';
Hash joins want memory and sane inputs
Hash joins are strong when PostgreSQL can build a hash table from one input and probe it from the other. They get ugly when the build side is much larger than estimated and spills into batches.
If the plan shows many batches or temp I/O, the problem may be memory, but it may also be the filter that failed to reduce the build side.
Merge joins need useful order
Merge joins can be excellent when both sides are already ordered by the join key or can be read that way from indexes. If PostgreSQL has to sort huge inputs first, the sort cost becomes part of the join problem.
Indexes should match the join and the filter
A join index on the foreign key is table stakes. The production win often comes from combining the join key with the filter or sort that makes this query important.
CREATE INDEX CONCURRENTLY orders_status_created_customer_idx
ON orders (status, created_at DESC, customer_id);
Use statistics when correlation fools the planner
If two predicates are correlated, single-column statistics may make the join input look much smaller or larger than it is. That is when higher statistics targets or extended statistics can matter.
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;
The join tuning sequence
- Run EXPLAIN ANALYZE with BUFFERS.
- Find the first major row estimate error.
- Check whether the join key has a useful index.
- Check whether filters happen early enough.
- Check temp I/O for hash or sort work.
- Change statistics, query shape, or index design based on the specific failure.
The production habit
The teams that get good at PostgreSQL performance do not chase every knob. They turn a vague complaint into a named failure mode, collect one clean measurement, make one change, and then compare the next measurement against the first. That rhythm is slower than guessing for the first hour and much faster by the end of the incident.