20. Join fan-out: diagnosing row-multiplication bugs
The bug that doesn't look like a bug
This is arguably the single most common real-world SQL mistake, and it's insidious precisely because it doesn't error. The query runs, returns a result, the result even looks plausible — it's just wrong, usually inflated, and you only notice when a total looks suspiciously large or a downstream report doesn't reconcile with a known-good number.
What "fan-out" actually is
A join multiplies rows whenever the join key isn't unique on at least
one side. If a customer has 5 rentals, joining customer to rental
on customer_id produces 5 rows for that customer — correctly, since
each row represents a genuine customer-rental pairing. But if you then
try to compute something about the customer (like "how many customers
do we have") from that joined result without accounting for the
multiplication, you'll overcount.
-- WRONG: this "counts customers" but actually counts customer-rental
-- PAIRS — a customer with 10 rentals gets counted 10 times.
SELECT count(*) AS customer_count
FROM customer c
JOIN rental r ON r.customer_id = c.customer_id;
-- Returns ~16000-ish (the rental count), not 599 (the actual customer count).
The join itself did exactly what it was asked — this isn't a join bug,
it's a mismatch between the grain of the join result and the grain of
the question being asked. "How many customers" is a question about the
customer grain (one row per customer); the joined result is at the
rental grain (one row per rental). Counting rows of a rental-grain
result never answers a customer-grain question correctly.
The fix: count distinct, or don't join at all
-- Correct: distinct customer_id, at the grain the question actually asks.
SELECT count(DISTINCT c.customer_id) AS customer_count
FROM customer c
JOIN rental r ON r.customer_id = c.customer_id;
-- Returns 599 (or fewer, if some customers had zero rentals and got
-- dropped by the INNER JOIN — a second, related trap).
-- Better still, if you didn't need any rental column: don't join at all.
SELECT count(*) FROM customer;
That last line is worth sitting with — a wrong-row-count bug is often a sign the query joined a table it didn't actually need data from. If you never reference a single column from the joined table outside of an aggregate, ask whether the join (and the fan-out risk it introduces) was necessary at all.
A second flavor: SUM inflated by a join
Same root cause, more dangerous because the wrong number looks even more plausible — a total that's simply too large, easy to mistake for "yeah that sounds about right":
-- WRONG: if a customer has multiple rentals, their address row gets
-- joined in once per rental — but address doesn't multiply the SPEND,
-- so this specific example is actually safe. The real danger case is
-- below, joining TWO tables that both have a one-to-many relationship
-- to the base table.
-- WRONG: film joined to BOTH film_actor and inventory — a film with 5
-- actors AND 3 inventory copies produces 5*3=15 rows for that one film,
-- and a naive SUM/COUNT over that joined result is inflated 15x for it.
SELECT f.title, count(*) AS suspicious_count
FROM film f
JOIN film_actor fa ON fa.film_id = f.film_id
JOIN inventory i ON i.film_id = f.film_id
GROUP BY f.title
ORDER BY suspicious_count DESC
LIMIT 5;
This double-fan-out pattern — joining two independent one-to-many relationships off the same base table in a single query — is a particularly common way to accidentally multiply, not just duplicate. The fix is almost always to separate the two aggregations (count actors and count inventory in two independent subqueries or CTEs, Module 5) rather than trying to compute both from one joined-and-multiplied result.
The diagnostic habit
Before trusting a count(*)/SUM(...) after any join, ask: what's the
grain of this joined result, and does it match the grain of the number
I'm computing? Concretely:
- Identify the "one" side and the "many" side of each join in the query (Module 8 formalizes this as cardinality — for now, just: does one row on the left match potentially several rows on the right?).
- If you're aggregating something that "belongs to" the one side (a
customer attribute, a film attribute), and the many side isn't
unique per one-side row, you likely need
DISTINCTinside the aggregate, or a restructured query. - When in doubt, run the join without the aggregate first and manually inspect a few rows for a known entity — does a customer you expect to see once actually appear once, or five times?
Check yourself
- Why doesn't
SELECT count(*) FROM customer JOIN rental ...error out even though it gives the wrong answer for "how many customers"? - What's the fix when you need a distinct-entity count from a joined, fanned-out result?
- A report shows total revenue 3x higher than finance's own number, and you suspect a join is the cause. What's the first diagnostic question to ask about the query's joins?