How do you check that a join did not multiply rows before trusting a SUM over it?
answer
- count the rows before you trust the sum
- compare the joined count to the parent count
- ask whether the key repeats on the other side
- GROUP BY the key, HAVING COUNT(*) > 1
- reconcile against a total computed without the join
basics
~20 sCompare the joined row count with the parent's: if COUNT() exceeds COUNT(DISTINCT parent_pk), the join duplicated parent rows and every additive aggregate over parent columns is inflated. Probe the child's key with GROUP BY … HAVING COUNT() > 1 first.
solid answer
~40 sTwo cheap probes catch almost every fan-out. **Before** writing the join, test whether the join key is unique on the other side: `SELECT order_id, COUNT(*) FROM order_items GROUP BY order_id HAVING COUNT(*) > 1` — any row returned means that table is many-per-order and will duplicate order rows. **After** writing it, compare `COUNT(*)` with `COUNT(DISTINCT o.order_id)` in the joined result; equality means one row per order survived, and any gap is the multiplication factor. A third check is a control total: `SELECT SUM(amount) FROM orders` alone must equal the revenue your joined query reports. Do this before adding aggregates, not after — an inflated `SUM` looks entirely plausible, and the usual way it is discovered is that someone notices the number moved when a table was added to the `FROM` clause.
code
sql · 11 lines-- 1. does the join key repeat on the child side?
SELECT order_id, COUNT(*) AS rows_per_order
FROM order_items
GROUP BY order_id
HAVING COUNT(*) > 1;
-- 2. did the join actually multiply the order rows?
SELECT COUNT(*) AS joined_rows,
COUNT(DISTINCT o.order_id) AS distinct_orders
FROM orders o
JOIN order_items i ON i.order_id = o.order_id;go deeper
Learn the two-count habit: run COUNT(*) and COUNT(DISTINCT parent_id) on the joined result before adding any SUM. If the two differ, the join duplicated rows and your total will be too high.
Explain what each probe proves — the HAVING COUNT(*) > 1 test shows the child is many-per-parent, the count comparison shows the multiplication actually happened — and read the case where parents were also dropped.
Show that you reconcile metrics against a control total computed without the join, and that you treat a number that moved when a table was added to the FROM clause as a defect until proven otherwise.
Make grain explicit as an engineering standard: documented row grain per table and per derived table, control totals in the test suite for anything feeding a metric, and constraints rather than convention enforcing join-key uniqueness.
## Why you need a check at all Join fan-out produces no error, no warning and no obviously wrong value. A revenue figure that is 2.7× too high is still a number with a currency sign on it. The distortion factor also varies per parent row — orders with many items are inflated more than orders with one — so the result is not even uniformly wrong in a way someone might notice as "about double". The only reliable defence is to check the *row multiplicity* of the join explicitly, before any aggregate hides it. ## Probe 1: is the join key unique on the other side? The question "will this join fan out?" is entirely answered by whether the join key is unique in the table being joined: ```sql SELECT order_id, COUNT(*) AS rows_per_order FROM order_items GROUP BY order_id HAVING COUNT(*) > 1 ORDER BY rows_per_order DESC; ``` If this returns nothing, the child is at most one row per order and joining it cannot duplicate anything. If it returns rows, the largest `rows_per_order` is the worst-case multiplier you are about to apply to every parent measure. The schema can answer this too — a `PRIMARY KEY` or `UNIQUE` constraint on the join key is a *guarantee*, not an observation. Distinguish the two: data that happens to be unique today can stop being unique tomorrow, while a constraint cannot. A query whose correctness rests on an unenforced assumption is a bug waiting for a row. ## Probe 2: did the join multiply the driving table? After the join is written, count rows before aggregating: ```sql SELECT COUNT(*) AS joined_rows, COUNT(DISTINCT o.order_id) AS distinct_orders FROM orders o JOIN order_items i ON i.order_id = o.order_id; ``` Interpretation is mechanical: - `joined_rows = distinct_orders` — one row per order survived; additive aggregates over `orders` columns are safe. - `joined_rows > distinct_orders` — fan-out; every `SUM`, `COUNT` and `AVG` over `orders` columns in that query is wrong. - `distinct_orders < (SELECT COUNT(*) FROM orders)` — the inner join also *dropped* orders with no items, a separate problem that a `LEFT JOIN` would fix. That third line matters: fan-out and row loss often coexist, and a total can be simultaneously inflated by duplication and deflated by dropped parents. ## Probe 3: the control total The most convincing check compares against a number computed without the join: ```sql SELECT SUM(amount) FROM orders; -- the truth ``` Whatever the reporting query says total revenue is, it must reconcile with this (allowing only for rows the query deliberately filters out). Control totals catch fan-out, accidental cross joins and dropped rows in one comparison, which is why they belong in the review of any query that feeds a metric. ## Habits that make the checks unnecessary - **Know the grain of every table in the `FROM` clause.** Write it in a comment: `-- one row per order`, `-- one row per shipment`. A join between two different grains is exactly where fan-out lives. - **Aggregate the child to the parent's grain before joining** whenever the child is many-per-parent, so the question never arises. - **Be suspicious of any number that moved when a table was added to the query** without the filter changing. Adding a join should not change a total that comes from a different table. - **Watch for a `SELECT DISTINCT` or a stray `DISTINCT` inside an aggregate** in inherited code. Both are usually scar tissue from a fan-out someone patched rather than fixed, and both can mask a genuine multiplicity problem while quietly deleting real duplicate detail rows. ## Interview framing Give the two probes concretely — the `HAVING COUNT(*) > 1` uniqueness test on the child and the `COUNT(*)` versus `COUNT(DISTINCT pk)` comparison on the join — and then say that you prefer to design the fan-out away by aggregating each child to the parent's grain first. Mentioning the control total shows you have actually had to defend a number to someone who cared about it.
- What does it mean if the joined result has FEWER distinct parent keys than the parent table has rows?The join dropped parents that had no matching child row — an inner join acting as a filter. That is a coverage problem rather than a fan-out problem, and the fix is `LEFT JOIN` if those parents belong in the result. Fan-out and row loss frequently occur together, inflating some rows while omitting others.
- Is checking the data enough, or should you check the schema?Check the schema. Data that is unique today is an observation; a PRIMARY KEY or UNIQUE constraint on the join key is a guarantee that survives tomorrow's inserts. A query whose correctness depends on unenforced uniqueness will break silently the first time a second row appears.
- You inherit a query full of SELECT DISTINCT. What does that suggest?Usually that someone hit fan-out and patched the symptom. DISTINCT over a joined result can mask multiplicity while also deleting legitimately identical detail rows, and it does nothing for a SUM. Re-derive the intended grain, aggregate the child sides properly, and see whether the DISTINCT is still needed — it rarely is.
saying these in an interview costs you the question
- Trusts a total because it looks plausible
- Only checks the query after a number is disputed
- Assumes a foreign key implies one row per parent
- Treats today's unique data as a uniqueness guarantee
- Adds DISTINCT until the row count looks right