Part 2 · 9 chapters · ~20 min

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.

13

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 ON conditions that are never TRUE return nothing.
  • Why the ON clause can contain any predicate, not just equality (chapter 21).
  • Why WHERE on an outer-joined table silently converts it to an inner join (chapter 14).
the vocabulary
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.
the join, from first principles
product → filter
swipe the figure sideways, or tap expand for full screen
1/5
two tables
Two small tables. Three rows on the left, two on the right.
14

INNER, LEFT, RIGHT, FULL

JoinReturnsNULL padding
INNEROnly matching pairsNone
LEFT OUTERAll left rows + matchesRight columns NULL when unmatched
RIGHT OUTERAll right rows + matchesLeft columns NULL when unmatched
FULL OUTERAll rows from bothEither side
Not in MySQL — emulate with UNION
CROSSEvery combinationNone

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).

code
-- 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-matches
the rule
On an outer join, a condition on the optional side belongs in ON. 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

code
-- 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.
15

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.

run it
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!
why fix 3 is a trap
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.
the diagnostic
If you find yourself reaching for 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.
fan-out
why the revenue total tripled
swipe the figure sideways, or tap expand for full screen
1/6
orders
Two orders. Their true combined total is 150.00 — that is the number any report should show.
16

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.

code
-- 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.

17

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.

IntentFormsFan-out?NULL-safe?
Semi-join
"has at least one"
WHERE EXISTS (SELECT 1 FROM ...)NoYes
WHERE x IN (SELECT ...)NoYes
JOIN ... + DISTINCTYes, then removedYes
Anti-join
"has none"
WHERE NOT EXISTS (...)NoYes
WHERE x NOT IN (...)NoNO — see ch 18
code
-- 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;
on performance folklore
"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.
18

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.

run it
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 happen
why this is so dangerous in practice
The query works in development, passes review, passes tests — and breaks in production the day one NULL appears in that column, possibly months later. The failure is silent and total: a report shows zero rows, a sync job processes nothing, an "inactive users" cleanup deletes nobody.

Default 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.
19

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.

code
-- 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.

when to reach for it
Top-N per group; calling a set-returning function per row; and any case where a correlated computation needs to return multiple columns or multiple rows — a scalar subquery can only return one value, so LATERAL is the general form.
20

NATURAL JOIN and USING

Two shorthands. One is useful; the other should be avoided, and it is worth knowing exactly why.

code
-- 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;
never use NATURAL JOIN
Its join condition is determined by column names, so it changes meaning when someone adds a column. Add a 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.

21

Non-equi joins

Nothing requires ON to use =. Any predicate works, and the useful cases are ranges and intervals.

code
-- 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 condition
the interval overlap rule, worth memorizing
Two half-open intervals [a1, a2) and [b1, b2) overlap if and only if a1 < 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.
the performance caveat
Hash join requires equality, so a pure non-equi join cannot use it (MySQL module, ch 79). That leaves nested loop with an index range scan per outer row. An index on the range column is essential, and a non-equi join between two large tables with no equality component is genuinely expensive — always include an equality predicate where the data allows it, as 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.