Hook: Interviewers love this because a join can look simple in SQL but become expensive or wrong once the optimizer chooses a plan.
Question: What is an execution plan for joins?
Answer: An execution plan is the database's chosen recipe for how it will read tables and combine matching rows. For joins, the optimizer decides the join order, the access path for each table, and the physical join method such as nested loop, hash join, or merge join. The plan matters because two SQL queries that look similar can have very different runtimes.
Interview-Ready Answer: An execution plan for a join is the database's step-by-step strategy for combining rows. I look for the join order, the join algorithm, and the scan type on each table, because those three choices usually explain why a query is fast or slow. If the plan is bad, I check statistics, indexes, and whether an outer-join filter was placed in the wrong clause.
An execution plan is the database's chosen path from SQL text to result rows. For joins, the plan shows which table is read first, how rows are matched, and whether the engine will loop, hash, or sort and merge the inputs.
EXPLAIN ANALYZE show estimated rows versus actual rows. If the estimate is far off, the planner may have chosen the wrong join method.| Algorithm | How it works | Best for | Watch out |
|---|---|---|---|
| Nested Loop | For each row in the outer input, probe the inner input | Small outer side or indexed lookup | Can explode to O(n*m) without an index |
| Hash Join | Build a hash table on one side, then probe it | Large equality joins | Needs memory; can spill to disk |
| Merge Join | Sort both inputs, then walk them in order | Already sorted data or range-friendly joins | Sorting can be expensive if inputs are unsorted |
Nested Loop, Hash Join, or Merge Join. That tells you the algorithm.Seq Scan means a full table read; Index Scan means the engine is using an index to find rows faster.Nested loop is great when one side is tiny or the inner side has a good index, but without that index it can behave like O(n*m). Hash join is often close to O(n + m) on average, but it needs memory for the hash table; if the build side is too large, the engine may batch or spill. Merge join is roughly O(n log n + m log m) if sorting is needed, but can be close to linear if both inputs are already ordered.
A very common failure pattern is stale statistics. The optimizer may think a join returns 200 rows when it really returns 2 million, so it picks nested loop and then spends seconds or minutes doing repeated scans.
WHERE after a LEFT JOIN can remove the null-extended rows and change the meaning of the query.NULL does not match =, so rows with null join keys do not match by equality.Memory hook: nested loop knocks on every door, hash join uses a phonebook, and merge join zips two sorted lines together.
Real-World Story: Imagine a checkout service that joins customers, orders, and payments to build an order summary page. One morning, traffic spikes and the page starts timing out because the planner thinks the join will return only a few hundred rows, so it picks a nested loop and repeatedly scans a much larger table. In the logs you see slow-query warnings, CPU near 100%, and EXPLAIN ANALYZE showing huge gaps between estimated rows and actual rows. The fix is usually a better index, updated statistics, or moving a filter so the database can reduce rows before the join.
There is also a correctness bug version of this story: a developer puts a right-table filter in the WHERE clause after a LEFT JOIN, and suddenly customers with no orders disappear from the report. The dashboard looks clean, but the numbers are wrong, and finance notices that the totals no longer match the source of truth.
-- PostgreSQL demo: plan choice + outer-join edge case
-- Run this whole script in a fresh session.
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS customers;
CREATE TABLE customers (
customer_id INT PRIMARY KEY,
customer_name TEXT NOT NULL
);
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer_id INT,
order_total NUMERIC(10,2) NOT NULL
);
-- Helpful for joins that probe orders by customer_id.
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
INSERT INTO customers (customer_id, customer_name) VALUES
(1, 'Ava'),
(2, 'Ben'),
(3, 'Chloe');
INSERT INTO orders (order_id, customer_id, order_total) VALUES
(101, 1, 25.00),
(102, 1, 80.00),
(103, 2, 55.00),
(104, NULL, 40.00);
-- Update statistics so the planner has better row-count estimates.
ANALYZE customers;
ANALYZE orders;
-- Good pattern: keep the right-table filter inside ON.
-- This preserves Chloe, even though she has no matching order >= 50.
EXPLAIN
SELECT c.customer_name, o.order_id, o.order_total
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.order_total >= 50
ORDER BY c.customer_id, o.order_id;
SELECT c.customer_name, o.order_id, o.order_total
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.order_total >= 50
ORDER BY c.customer_id, o.order_id;
-- Edge case: NULL join keys do not match on equality.
-- Order 104 still appears, but the customer columns are NULL.
EXPLAIN
SELECT o.order_id, c.customer_name
FROM orders o
LEFT JOIN customers c
ON c.customer_id = o.customer_id
ORDER BY o.order_id;
SELECT o.order_id, c.customer_name
FROM orders o
LEFT JOIN customers c
ON c.customer_id = o.customer_id
ORDER BY o.order_id;
-- Failure path: moving the filter into WHERE turns the LEFT JOIN
-- into the effect of an INNER JOIN for rows with NULL-extended right side.
EXPLAIN
SELECT c.customer_name, o.order_id, o.order_total
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.order_total >= 50
ORDER BY c.customer_id, o.order_id;
SELECT c.customer_name, o.order_id, o.order_total
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.order_total >= 50
ORDER BY c.customer_id, o.order_id;Follow-up & Tricky Questions:
ON and WHERE in an outer join? ON decides which rows match while keeping unmatched rows alive in a LEFT JOIN. WHERE filters after the join, so it can remove those unmatched rows.LEFT JOIN with a right-table filter in WHERE still behave like a left join? Often not. The null-extended rows get removed, so the result behaves like an inner join for those rows.Common Mistakes:
WHERE. Correction: keep row-preserving conditions in ON.Memory Hook: Knock, Phonebook, Zipper — nested loop knocks on each door, hash join uses a phonebook to find matches fast, and merge join zips two sorted lists together.
Cheat Sheet:
ON versus WHERE.Practice Tasks:
EXPLAIN on an INNER JOIN and a LEFT JOIN, then note the join type.ON to WHERE and compare the result rows.ANALYZE, and see how the plan changes.