Joins in Depth
Normalised data lives in several tables. A join reassembles it: it pairs rows from two inputs according to a condition, then keeps or drops pairs depending on the join type.
Almost every "the numbers are wrong" bug in application SQL is a join problem — wrong type, wrong condition, or a cardinality nobody checked. This chapter covers all of it.
1. What a join really is
Conceptually, a join is two steps:
- Build candidate pairs — the Cartesian product of the two tables (every row × every row).
- Filter pairs — keep those where the
ONcondition is true.
No real database materialises the product; it uses indexes and hash tables to find matches directly. But this mental model explains the two things people get wrong:
- The
ONclause is a filter on pairs, not "the join direction". - The number of output rows is determined by how many pairs match — the cardinality.
FROM orders AS o
JOIN users AS u ON u.id = o.user_id
If orders has 180 rows and every order has exactly one user, the result has 180 rows. If each user had three matching rows in the other table, the result would have 540. Joins multiply, they don't merge.
Cardinality vocabulary
| Relationship | Example | Effect on row count |
|---|---|---|
| 1:1 | users.id = users_profile.user_id |
unchanged |
| 1:N | users.id = orders.user_id |
one row per child |
| N:M | orders ↔ products via order_items |
one row per pair |
Before writing a join, state the relationship. If you cannot, you do not yet know what the query returns.
2. The join types at a glance
Take A (left) and B (right), joined on a condition:
| Type | Rows kept | SQL |
|---|---|---|
| INNER | only pairs that match | A JOIN B ON ... |
| LEFT | all of A; matching B or NULLs |
A LEFT JOIN B ON ... |
| RIGHT | all of B; matching A or NULLs |
A RIGHT JOIN B ON ... |
| FULL OUTER | all of A and all of B |
A FULL JOIN B ON ... |
| CROSS | every pair (no condition) | A CROSS JOIN B |
LEFT JOIN and RIGHT JOIN are the same operation with the arguments swapped. That is why experienced engineers write everything as LEFT JOIN: you read top-to-bottom ("keep users, attach their orders") instead of having to reverse your thinking. RIGHT JOIN is not wrong — just harder to review.
3. INNER JOIN — the default
Keeps only rows with a match on both sides. This is what plain JOIN means.
SELECT o.id AS order_id,
u.full_name,
o.status,
o.total_cents / 100.0 AS total
FROM orders AS o
INNER JOIN users AS u ON u.id = o.user_id
WHERE o.status = 'delivered'
ORDER BY o.placed_at DESC
LIMIT 10;
Use it when: the child row is meaningless without the parent ("orders always have a user"). Rows with no match are supposed to vanish.
Watch out: an INNER JOIN silently drops orphans. If a foreign key is missing or nullable, you may lose data you expected to see. When in doubt, LEFT JOIN and inspect the NULLs.
-- How many orders point at a missing user? (Should be zero with a FK.)
SELECT COUNT(*) AS orphan_orders
FROM orders AS o
LEFT JOIN users AS u ON u.id = o.user_id
WHERE u.id IS NULL;
4. LEFT JOIN — keep the left side
Keeps every row from the left table, attaching matches from the right. When there is no match, right-side columns are NULL.
SELECT u.id,
u.full_name,
COUNT(o.id) AS order_count
FROM users AS u
LEFT JOIN orders AS o ON o.user_id = u.id
GROUP BY u.id, u.full_name
ORDER BY order_count ASC, u.id
LIMIT 15;
The COUNT trap
COUNT(*) counts rows, and an unmatched LEFT JOIN still produces one row — full of NULLs. So COUNT(*) reports 1 for a user with zero orders. Count a right-side column instead, because aggregates ignore NULLs:
SELECT COUNT(*) AS wrong_count,
COUNT(o.id) AS right_count
FROM users AS u
LEFT JOIN orders AS o ON o.user_id = u.id
WHERE u.id = 51;
Use it when: "all X, plus their Y if any" — dashboards, "customers with no orders", optional relationships.
LEFT JOIN ... IS NULL = anti-join
The unmatched rows are exactly the ones with a NULL key on the right:
SELECT u.id, u.full_name, u.created_at
FROM users AS u
LEFT JOIN orders AS o ON o.user_id = u.id
WHERE o.id IS NULL
ORDER BY u.id;
This is an anti-join ("rows in A with no match in B"). The NOT EXISTS form in §9 is usually clearer and optimises at least as well.
5. RIGHT JOIN — the mirror image
Keeps every row from the right table. It is LEFT JOIN with the tables swapped:
SELECT p.name, oi.order_id
FROM order_items AS oi
RIGHT JOIN products AS p ON p.id = oi.product_id
WHERE oi.id IS NULL
ORDER BY p.name;
The query above finds products that have never been ordered. It is correct, and most reviewers would prefer:
SELECT p.name
FROM products AS p
LEFT JOIN order_items AS oi ON oi.product_id = p.id
WHERE oi.id IS NULL
ORDER BY p.name;
Same result, same plan, easier to read. Use RIGHT JOIN only when the "driving" table genuinely belongs on the right for readability of a complex query.
6. FULL OUTER JOIN — reconciliation
Keeps unmatched rows from both sides. The classic use case is comparing two datasets: production vs. staging, this month vs. last month, two sources of truth.
-- Compare the line items total with the cached order total.
WITH item_totals AS (
SELECT order_id, SUM(quantity * unit_price_cents) AS computed_cents
FROM order_items
GROUP BY order_id
)
SELECT o.id AS order_id,
o.total_cents AS stored_cents,
it.computed_cents AS computed_cents
FROM orders AS o
FULL OUTER JOIN item_totals AS it ON it.order_id = o.id
WHERE o.total_cents IS DISTINCT FROM it.computed_cents
ORDER BY o.id
LIMIT 10;
IS DISTINCT FROM is required here — a plain <> returns unknown when either side is NULL, hiding exactly the mismatches you are hunting.
[!NOTE] MySQL has no
FULL OUTER JOIN. Emulate it withLEFT JOIN ... UNION ALL ... RIGHT JOIN ...(orUNIONon the two anti-joins). SQL Server, PostgreSQL, Oracle and SQLite 3.39+ support it.
Use it when: reconciling two sources; almost never in application CRUD.
7. CROSS JOIN — every combination
No condition: each left row pairs with each right row. n × m rows. It is the tool for generating data rather than finding relationships:
-- Every user × every status: the complete grid, including zero counts.
SELECT u.id, s.status
FROM users AS u
CROSS JOIN (VALUES ('pending'), ('paid'), ('shipped')) AS s(status)
WHERE u.id <= 3
ORDER BY u.id, s.status;
Also used for date spines (every day × region) and matrix reports. A missing join condition is also a cross join — which is why a forgotten ON clause is so dangerous:
-- 180 orders × 60 users = 10,800 rows. Probably not what you meant.
SELECT COUNT(*) FROM orders CROSS JOIN users;
8. SELF JOIN — a table joined to itself
Hierarchies ("employee → manager"), comparisons ("same city as"), and consecutive rows are self joins. Aliases are mandatory, because the table appears twice.
SELECT e.full_name AS employee,
e.department,
m.full_name AS manager
FROM employees AS e
LEFT JOIN employees AS m ON m.id = e.manager_id
ORDER BY e.department, e.full_name;
LEFT JOIN matters: the CEO has manager_id IS NULL and would disappear with an inner join. Chapter 7 shows the recursive-CTE version for whole reporting chains.
9. Anti-joins and semi-joins
Two patterns deserve names, because they express intent better than the "shape" of the SQL.
Semi-join: "does at least one match exist?"
SELECT u.id, u.full_name
FROM users AS u
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.user_id = u.id AND o.status = 'cancelled'
);
EXISTS stops at the first match, does not duplicate rows, and is NULL-safe. IN (SELECT ...) is the other semi-join spelling; IN de-duplicates the subquery and can behave badly if the inner column is nullable.
Never write SELECT DISTINCT ... JOIN ... just to express existence — EXISTS says what you mean and lets the planner stop early.
Anti-join: "no match exists"
SELECT u.id, u.full_name
FROM users AS u
WHERE NOT EXISTS (
SELECT 1 FROM orders AS o WHERE o.user_id = u.id
);
Equivalent forms:
| Form | NULL-safe? | Notes |
|---|---|---|
NOT EXISTS (...) |
Yes | preferred; planner turns it into a hash/merge anti-join |
LEFT JOIN ... WHERE right.id IS NULL |
Yes | fine, but only if the IS NULL column is NOT NULL in the right table |
NOT IN (SELECT nullable_col ...) |
No | returns zero rows if any value is NULL |
COUNT(*) = 0 per group |
Yes | slow, unreadable |
The NOT IN failure mode is worth burning into memory. If the subquery returns any NULL, the whole predicate is unknown and no row passes — no error, just silence.
10. The ON vs WHERE trap
With an outer join, ON and WHERE are not interchangeable:
ONdecides which pairs match. Unmatched left rows survive with NULLs.WHEREfilters the final rows — including the NULL-extended ones.
-- Every user is kept: cancelled orders simply appear as NULL.
SELECT u.id, o.id AS order_id
FROM users AS u
LEFT JOIN orders AS o
ON o.user_id = u.id
AND o.status = 'cancelled'
WHERE u.id <= 5
ORDER BY u.id;
-- Only users who actually have a cancelled order are kept.
SELECT u.id, o.id AS order_id
FROM users AS u
LEFT JOIN orders AS o ON o.user_id = u.id
WHERE u.id <= 5
AND o.status = 'cancelled'
ORDER BY u.id;
With an INNER JOIN the two produce the same rows, which is why the bug only appears after someone changes JOIN to LEFT JOIN.
Rule: filter the right table's attributes in ON when the left side must survive; filter final results in WHERE.
11. Compound, non-equi, and post-join joins
Joins are not limited to id = id.
-- Compound key: an order "contains" a product once.
SELECT oi.order_id, oi.product_id, oi.quantity
FROM order_items AS oi
JOIN products AS p
ON p.id = oi.product_id
AND p.in_stock
LIMIT 10;
-- Non-equi (inequality) join: who earns less than their department manager?
SELECT e.full_name, e.salary_cents / 100.0 AS salary,
m.full_name AS manager, m.salary_cents / 100.0 AS manager_salary
FROM employees AS e
JOIN employees AS m
ON m.id = e.manager_id
AND e.salary_cents < m.salary_cents
ORDER BY e.salary_cents DESC;
Range joins (BETWEEN, <, >=) are common for time windows and price bands, but they cannot use a plain B-tree equality lookup — expect a nested-loop or hash plan, and keep inputs small.
LATERAL (PostgreSQL, MySQL 8.0.14+, SQL Server via CROSS APPLY, Oracle via CROSS APPLY) is a join whose right side may reference the left row — the tool for "top 3 orders per user" without window functions. See chapter 3.
12. Fan-out: the duplicate factory
A 1:N join multiplies the left row. If you then aggregate the left side's column, you count it once per child:
-- Wrong: the order total is repeated once per line item.
SELECT SUM(o.total_cents) / 100.0 AS inflated_revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.id;
-- Right: aggregate each table at its own grain, then combine.
SELECT (SELECT SUM(total_cents) FROM orders) / 100.0 AS order_revenue,
(SELECT SUM(quantity * unit_price_cents) FROM order_items) / 100.0 AS item_revenue;
The same fan-out explains:
COUNT(*)larger than the number of parents.AVGskewed toward parents with more children (weighted average instead of row average).DISTINCTsprinkled everywhere to "fix" duplicates — that hides the bug and costs a sort.
Diagnostic: run SELECT COUNT(*) on each input and on the joined result. If the join output is larger than the "one row per parent" count, you have fan-out. Decide whether you want per-parent aggregation (GROUP BY parent.id) or pre-aggregated subqueries.
13. How the planner executes a join
You write one join; the planner picks an algorithm per join:
| Algorithm | How it works | Good when |
|---|---|---|
| Nested loop | For each outer row, look up matches in the inner table (ideally an index) | small outer side, indexed inner side, LIMIT queries |
| Hash join | Build a hash table on the smaller input, probe it with the larger | large unsorted inputs, equality joins |
| Merge join | Sort both inputs on the key, then zip them | both inputs already sorted / index order, large data |
Consequences:
- An index on the join key (
orders.user_id) turns a nested loop into a fast lookup; without it, you get repeated sequential scans. - The planner chooses the build side of a hash join based on statistics — stale
ANALYZEdata leads to bad choices. - Join order is not your
FROMorder. The planner reorders inner joins freely;JOINorder only constrainsLEFT/RIGHTsemantics.
Chapter 10 shows how to read this in EXPLAIN ANALYZE.
14. Choosing the join type
| Question | Answer |
|---|---|
| Must a match exist? | JOIN (inner) |
| Must every left row survive? | LEFT JOIN |
| Must every right row survive? | RIGHT JOIN, or swap to LEFT JOIN |
| Must both survive (reconciliation)? | FULL OUTER JOIN |
| Do I need all combinations? | CROSS JOIN |
| Do I need existence, not columns? | EXISTS / NOT EXISTS |
| Does the right side depend on the left row? | JOIN LATERAL |
| Same table twice? | self join with aliases |
15. Practice
Try these in the playground before reading the solutions — they combine everything above.
- List every user with their number of delivered orders, including users with zero.
- Find products that have never appeared in
order_items. - For each order, show the user's country and the number of distinct products in the order.
- Show every employee and their manager's manager (two self joins).
- Why can the following query return fewer rows than
SELECT COUNT(*) FROM users?SELECT COUNT(*) FROM users JOIN orders ON orders.user_id = users.id;
[!TIP] ELI5: Joins are introductions
INNER JOINis a party where you only talk to people you already know.LEFT JOINis you standing at the door greeting everyone on the guest list and writing "didn't show" for the no-shows.FULL OUTER JOINis comparing two guest lists after the party to find both no-shows and crashers.CROSS JOINis introducing every guest to every other guest — and is exactly why the room gets crowded.
16. Checklist
- I can name the grain of the result ("one row per …").
- I know the cardinality of each join (1:1 / 1:N / N:M).
- Outer-join right-table filters are in
ON, notWHERE. -
COUNT(*)on aLEFT JOINis intentional; otherwiseCOUNT(right.id). - Existence checks use
EXISTS, notDISTINCTorCOUNT(*) > 0. - No
NOT INagainst a nullable column. - Join keys are indexed and have fresh statistics.
- The result row count matches my expectation before I aggregate it.