Module 16 · Query Optimization Home · Engineering Guide · Cheatsheet · Decision Trees
Lesson 04 of 12 · ← SARGability · Next: Subqueries → · SQL Lab
You already know how to write a JOIN. This lesson covers what the
optimizer does with it — which of three join algorithms it picks, why,
and when you can influence that choice.
JOIN syntax is one line of SQL, but the optimizer’s choice of how to
execute it can be the single biggest factor in a query’s runtime — the same
join written the same way can run a hundred times faster or slower purely
based on table sizes and indexes, with no SQL changes at all.
An e-commerce reporting query joins orders (200M rows) against a small
promotions lookup table (40 rows) to tag which orders used a promo code.
The optimizer’s choice here — probably a hash join or even a broadcast-style
join of the tiny table — is completely different from joining two
similarly-huge tables, where a merge or hash join on indexed/sorted keys
dominates. Understanding why the optimizer picks differently for these
two shapes is what separates “I can write a JOIN” from “I understand joins.”
for each row in outer_table:
for each matching row in inner_table (often via an index):
emit joined row
Efficient when the outer table is small and the inner table has a usable
index on the join column — the classic case of joining a small departments
table against a large employes table filtered down to a small subset.
Becomes very expensive if the outer table is large and there’s no index to
speed up the inner lookup, since it degrades toward comparing every row
against every row.
1. Build a hash table from the smaller input, keyed on the join column
2. Probe the hash table with each row of the larger input
The default choice for joining two large tables with no useful index on the join column — no ordering or index required, and it scales well as long as the smaller (build) side fits comfortably in memory.
1. Ensure both inputs are sorted on the join column (via index or explicit sort)
2. Walk both sorted inputs together, like merging two sorted lists
Very efficient when both inputs are already sorted on the join column — typically because both have an index on it — since it avoids building any in-memory structure. Expensive if the engine has to sort one or both sides first just to enable the merge.
flowchart TD
A[Join Required] --> B{Outer table small<br/>+ index on inner join column?}
B -->|Yes| C[Nested Loop Join]
B -->|No| D{Both inputs large<br/>+ no useful index?}
D -->|Yes| E[Hash Join]
D -->|No| F{Both inputs sorted<br/>on join column?}
F -->|Yes| G[Merge Join]
F -->|No| H[Optimizer cost model<br/>chooses cheapest]
H --> C
H --> E
H --> G
The optimizer estimates the cost of each algorithm for the specific tables, sizes, and available indexes involved, and picks the lowest estimated cost. This is why the same JOIN syntax in your query can compile to different physical algorithms depending on: table sizes, whether either join column is indexed, and current statistics.
SELECT e.emp_name, d.dept_name
FROM employes e
JOIN departments d ON e.dept_id = d.dept_id
WHERE d.location_id = 3;
The optimizer typically filters departments down to location_id = 3
before performing the join — pushing the WHERE predicate down past
the join boundary — rather than joining everything first and filtering
after. This dramatically shrinks the join’s build/probe side.
If a query joins a table but never actually references any of its columns
in SELECT, WHERE, or elsewhere, and a foreign key guarantees the join
can’t change the row count or filter anything, a sufficiently smart
optimizer can eliminate the join entirely. This mostly benefits
auto-generated ORM SQL that joins defensively; it’s worth knowing this
exists so you don’t manually “optimize away” a join the optimizer would
have removed anyway.
EXPLAIN output as
Nested Loop, Hash Join, or Merge Join (PostgreSQL naming; other
engines use similar labels).JOIN order in the SQL text controls execution orderON UPPER(e.dept_code) = UPPER(d.code)), which forces a nested loop with
no usable index on either sidejoin_collapse_limit / geqo_threshold), which can
produce a suboptimal plan for very wide joins.EXPLAIN ANALYZE as a sudden jump in actual time relative to
estimated cost.work_mem in
PostgreSQL) rather than assuming the join algorithm choice itself is wrong.In distributed/MPP engines (Redshift, Snowflake, BigQuery), join cost includes a data shuffle step to co-locate matching rows across nodes — a dimension that doesn’t exist in single-node engines. Joining on a poorly-distributed key can dominate query time even when the same join would be trivial on a single-node database.
USE_NL, USE_HASH,
USE_MERGE) for cases where the optimizer’s default choice needs manual
override.The optimizer picks a join algorithm based on table sizes, available indexes, and estimated cost — not based on how you wrote the SQL. Your job is to give it good indexes and accurate statistics; its job is to pick nested loop, hash, or merge accordingly.
locations table and a 40-million-row
employes-derived table, predict the likely join algorithm and explain
why.EXPLAIN, which join is likely to be the most expensive.These scenarios are illustrative case studies inspired by common production failure modes; specific figures are illustrative unless an external source is cited in PRODUCTION_INCIDENTS.md. See its disclaimer for the full explanation.
Batches > 1 in PostgreSQLHash Join with Batches > 1 in PostgreSQL — this means the build side exceeded work_mem and spilled to disk; increase work_mem or reduce build-side rows via predicate pushdown.ON e.dept_id = d.dept_id AND d.dept_name = 'Engineering') so the optimizer applies them before the join, not after.“Which join algorithm would you expect here?” is a classic systems-design question. Interviewers give table sizes and index availability, then expect you to predict nested loop (small outer + indexed inner), hash join (two large unindexed tables), or merge join (both sides pre-sorted). Mentioning hash join spill-to-disk and predicate pushdown separates senior candidates from those who only know the three names.
departments (small) to employes (large) with and without an index on employes.dept_id — compare nested loop vs. hash join in EXPLAIN ANALYZE.EXPLAIN — check whether the optimizer’s order matches your prediction.WHERE clause and observe whether the optimizer pushes it below the join node (predicate pushdown) in the plan tree.03_SARGability · 05_Subquery Optimization · 03_Joins