Joins, properly
Joins are where Part 1's two rules — three-valued logic and bag semantics —
combine to produce the most expensive mistakes in SQL. A join that silently
multiplies rows corrupts every aggregate downstream, and a NOT IN
against a nullable column returns nothing at all, with no error. Both have the
same cure: knowing precisely what a join is.
Every join is a filtered cross join
The definition that makes everything else derivable. A join is defined as:
form the Cartesian product of both tables, then keep the rows where the
ON condition is TRUE.
No engine actually builds the product — that is what the join algorithms in the MySQL module's chapter 79 avoid. But the semantics are defined this way, and reasoning from it answers questions that otherwise need memorizing:
- Why a join can return more rows than either input (chapter 15).
- Why
ONconditions that are never TRUE return nothing. - Why the
ONclause can contain any predicate, not just equality (chapter 21). - Why
WHEREon an outer-joined table silently converts it to an inner join (chapter 14).
CROSS JOIN is the product with no filter. INNER JOIN
is the product filtered by ON. An outer join is the inner join
plus the unmatched rows from the preserved side, padded with NULLs.
Every join type is one of those three ideas.INNER, LEFT, RIGHT, FULL
| Join | Returns | NULL padding |
|---|---|---|
INNER | Only matching pairs | None |
LEFT OUTER | All left rows + matches | Right columns NULL when unmatched |
RIGHT OUTER | All right rows + matches | Left columns NULL when unmatched |
FULL OUTER | All rows from both | Either side Not in MySQL — emulate with UNION |
CROSS | Every combination | None |
The mistake that undoes a LEFT JOIN
Putting a condition on the right table in WHERE instead of
ON. The rows you wanted to preserve get NULLs in those columns at
step 1, and WHERE at step 2 then discards them — because
NULL = 'x' is UNKNOWN, not TRUE (chapter 8).
-- intent: every customer, with their 2026 orders if any
-- BROKEN: silently becomes an INNER JOIN
SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.year = 2026; -- customers with no orders: o.year is NULL
-- → UNKNOWN → row discarded
-- CORRECT: the condition belongs to the join
SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.id
AND o.year = 2026; -- filters the join, preserves the left side
-- the exception that IS correct: an anti-join (ch 17)
SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL; -- deliberately keeps only non-matchesON. A condition in WHERE against that side converts
the join to an inner join — unless it is an IS NULL test, which is
the anti-join idiom and deliberate.
On an inner join it makes no difference which clause you use, because there is no NULL padding. That is why the habit is easy to acquire and hard to notice.
Prefer LEFT over RIGHT. They are equivalent under table
reordering, and reading a query where the preserved side is sometimes on the
left and sometimes on the right is needlessly hard. Most style guides ban
RIGHT JOIN entirely.
FULL OUTER in MySQL
-- MySQL has no FULL OUTER JOIN. the portable emulation: SELECT a.*, b.* FROM a LEFT JOIN b ON a.k = b.k UNION SELECT a.*, b.* FROM a RIGHT JOIN b ON a.k = b.k; -- UNION (not UNION ALL) is required here — the inner-matching -- rows appear in both halves and must be deduplicated.
Join fan-out
The single most damaging join bug, because it produces plausible wrong numbers rather than an error.
If a row on the left matches three rows on the right, it appears three times in the result. Any aggregate over the left table's columns is now computed over three copies.
CREATE TABLE orders (id INT PRIMARY KEY, total DECIMAL(10,2));
CREATE TABLE items (id INT PRIMARY KEY, order_id INT, sku VARCHAR(20));
INSERT INTO orders VALUES (1, 100.00), (2, 50.00);
INSERT INTO items VALUES
(1,1,'A'), (2,1,'B'), (3,1,'C'), -- order 1 has THREE items
(4,2,'D'); -- order 2 has one
-- the truth
SELECT SUM(total) FROM orders; -- 150.00
-- the bug: total is counted once PER ITEM
SELECT SUM(o.total)
FROM orders o JOIN items i ON i.order_id = o.id; -- 350.00 !
-- ── three correct fixes ──
-- 1. aggregate BEFORE joining (usually best)
SELECT SUM(o.total), SUM(i.n)
FROM orders o
LEFT JOIN (SELECT order_id, COUNT(*) AS n
FROM items GROUP BY order_id) i
ON i.order_id = o.id; -- 150.00
-- 2. a correlated scalar subquery
SELECT o.id, o.total,
(SELECT COUNT(*) FROM items i WHERE i.order_id = o.id) AS n
FROM orders o;
-- 3. DISTINCT the aggregate (works, but often masks the issue)
SELECT SUM(DISTINCT o.total) ... -- WRONG if two orders
-- share a total!SUM(DISTINCT o.total) deduplicates by value, not by row.
Two different orders that both total 100.00 collapse into one. It appears to
fix the test case and silently corrupts real data — the worst possible
property for a fix.SELECT DISTINCT or
COUNT(DISTINCT ...) to make numbers look right, stop: you almost
certainly have fan-out. Count the rows before and after the join. If the join
increased the row count and you are aggregating the left side, the aggregate is
wrong.Self joins and hierarchies
A table joined to itself, with aliases distinguishing the roles. Nothing special happens — it is the same filtered product, and the aliases are what make it readable.
-- employees and their managers SELECT e.name AS employee, m.name AS manager FROM employees e LEFT JOIN employees m ON m.id = e.manager_id; -- LEFT, so the CEO (manager_id IS NULL) is not dropped -- consecutive rows: pair each with the next SELECT a.reading, b.reading AS next_reading FROM samples a JOIN samples b ON b.seq = a.seq + 1; -- a window function (LAG/LEAD, ch 34) is better here: -- it needs one pass instead of a join.
A self join walks one level of a hierarchy per join. Two levels need two joins, and arbitrary depth needs recursion — which is chapter 43.
Semi-joins and anti-joins
A semi-join asks "does a match exist?" and returns left rows once, regardless of how many matches there are. An anti-join returns left rows with no match. Neither is spelled with a keyword; both are expressed indirectly.
| Intent | Forms | Fan-out? | NULL-safe? |
|---|---|---|---|
| Semi-join "has at least one" | WHERE EXISTS (SELECT 1 FROM ...) | No | Yes |
WHERE x IN (SELECT ...) | No | Yes | |
JOIN ... + DISTINCT | Yes, then removed | Yes | |
| Anti-join "has none" | WHERE NOT EXISTS (...) | No | Yes |
WHERE x NOT IN (...) | No | NO — see ch 18 |
-- semi-join: customers who have ordered (each listed once) SELECT c.* FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id); -- anti-join: customers who never have SELECT c.* FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id); -- the LEFT JOIN / IS NULL idiom does the same thing SELECT c.* FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE o.id IS NULL;
EXISTS is faster than IN" was true before 5.6, when
MySQL executed correlated subqueries literally, once per outer row. Modern
optimizers transform all three forms into the same semijoin plan
(MySQL module, ch 81). Choose for clarity and NULL-safety; measure if it
matters.
SELECT 1 versus SELECT * inside EXISTS
makes no difference either — the optimizer never evaluates the select list.The NOT IN + NULL trap
The highest-value chapter in this part, because it produces an empty result set with no error, and almost nobody predicts it.
x NOT IN (a, b, c) is defined as
x <> a AND x <> b AND x <> c. If any element is
NULL, that comparison is UNKNOWN, and from chapter 8's AND table:
TRUE AND UNKNOWN = UNKNOWN. The row is discarded.
So if the subquery returns even one NULL, NOT IN
returns no rows at all.
CREATE TABLE c (id INT);
CREATE TABLE o (customer_id INT); -- nullable!
INSERT INTO c VALUES (1),(2),(3);
INSERT INTO o VALUES (1); -- only customer 1 ordered
-- works: expect customers 2 and 3
SELECT * FROM c WHERE id NOT IN (SELECT customer_id FROM o);
-- → 2, 3 ✓
INSERT INTO o VALUES (NULL); -- one orphan row appears
-- SAME query. now returns NOTHING.
SELECT * FROM c WHERE id NOT IN (SELECT customer_id FROM o);
-- → (empty) ✗ no error. no warning.
-- why: for customer 2 the expansion is
-- 2 <> 1 AND 2 <> NULL
-- TRUE AND UNKNOWN → UNKNOWN → row dropped
-- ── the fixes ──
-- 1. NOT EXISTS — immune, and the recommended default
SELECT * FROM c
WHERE NOT EXISTS (SELECT 1 FROM o WHERE o.customer_id = c.id);
-- 2. exclude NULLs explicitly
SELECT * FROM c
WHERE id NOT IN (SELECT customer_id FROM o
WHERE customer_id IS NOT NULL);
-- 3. best of all: make the column NOT NULL so it cannot happenDefault to
NOT EXISTS. It behaves identically when there
are no NULLs and correctly when there are, so there is no reason to prefer
NOT IN for a subquery.LATERAL — the join that sees the left row
Normally a subquery in FROM is evaluated independently and cannot
reference other tables in the same FROM.
LATERAL removes that restriction: the subquery is re-evaluated for
each left row and can reference its columns.
This is the clean answer to top-N-per-group, which is otherwise awkward.
-- the three most recent orders for each customer
-- Postgres / MySQL 8.0.14+
SELECT c.name, o.id, o.created_at
FROM customers c,
LATERAL (
SELECT id, created_at FROM orders
WHERE customer_id = c.id -- ← references the LEFT row
ORDER BY created_at DESC
LIMIT 3
) o;
-- LEFT JOIN LATERAL keeps customers with no orders
SELECT c.name, o.id
FROM customers c
LEFT JOIN LATERAL (...) o ON TRUE; -- ON TRUE is required
-- SQL Server spells it CROSS APPLY / OUTER APPLY
SELECT c.name, o.id FROM customers c CROSS APPLY (...) o;The alternative without LATERAL is a window function
(ROW_NUMBER() filtered to <= 3, chapter 33), which
computes the ranking over every row and then discards most of them.
LATERAL can stop after three per group, so on a large table with a
supporting index it is usually faster.
LATERAL is the
general form.NATURAL JOIN and USING
Two shorthands. One is useful; the other should be avoided, and it is worth knowing exactly why.
-- NATURAL JOIN: joins on ALL columns with matching names. SELECT * FROM orders NATURAL JOIN customers; -- USING: joins on the named columns, which must exist in both. SELECT * FROM orders JOIN customers USING (customer_id); -- equivalent explicit form SELECT * FROM orders o JOIN customers c ON o.customer_id = c.customer_id;
created_at to both
tables — a routine migration — and the join silently starts requiring the
timestamps to match too, returning far fewer rows. No error, no warning, and
the query text did not change.
It makes your query's correctness depend on a schema property nobody is watching.
USING is safe and has two genuine advantages: the joined column
appears once in SELECT * output rather than twice,
and it can be referenced unqualified afterwards. Use it when the column names
genuinely match; use explicit ON otherwise.
Non-equi joins
Nothing requires ON to use =. Any predicate works,
and the useful cases are ranges and intervals.
-- band join: place each value into a bracket
SELECT s.amount, b.label
FROM sales s
JOIN brackets b
ON s.amount >= b.lo AND s.amount < b.hi;
-- temporal: which price was in effect at the time of sale
SELECT s.id, p.price
FROM sales s
JOIN prices p
ON p.sku = s.sku
AND s.sold_at >= p.valid_from
AND s.sold_at < p.valid_to;
-- overlap detection: the classic interval test
SELECT a.id, b.id
FROM bookings a
JOIN bookings b
ON a.room = b.room
AND a.id < b.id -- each pair once, not twice
AND a.starts < b.ends
AND b.starts < a.ends; -- ← the overlap conditiona1 < b2 AND b1 < a2. Two comparisons, no cases. People
routinely write four OR-ed conditions enumerating the
configurations, get one wrong, and ship an off-by-one that only shows up on
touching intervals.p.sku = s.sku does above.Part 3 covers what happens after rows are combined: grouping, aggregation, and
the NULL rules that make AVG mean something other than what people
assume.