SQL Quest › SQL Interview Questions › SQL traps › Join fan-out

SQL join fan-out: why SUM and COUNT inflate after a JOIN

Joining two child tables through their parent (instead of on the key that links them) repeats each row once per match, so every SUM and COUNT after the join is inflated.

The task

Report card spend and the number of chargebacks for each merchant risk tier. chargebacks carries both merchant_id and txn_id, and the analyst joins it on merchant_id. The data is SQL Quest's synthetic card-transactions set (accounts, merchants, transactions, chargebacks), and every result on this page is what the query returns on it.

The query that looks right

SELECT m.risk_tier,
       ROUND(SUM(t.amount), 2) AS spend,
       COUNT(cb.chargeback_id) AS chargebacks
FROM merchants m
JOIN transactions t  ON t.merchant_id = m.merchant_id
JOIN chargebacks cb  ON cb.merchant_id = m.merchant_id
GROUP BY m.risk_tier
ORDER BY m.risk_tier;
risk_tierspendchargebacks
high243,172.54878
low488,731.163,829
medium344,141.672,016

High-risk merchants appear to have 243,172.54 of spend and 878 chargebacks.

Why

Each transaction at a merchant is paired with every chargeback at that merchant, not with its own. Aurora Cafe, a high-risk merchant with 90 transactions and 5 chargebacks, becomes 450 joined rows where there should be 90.

SUM then adds every transaction amount once per chargeback, and COUNT counts the pairs. Nothing about the result looks broken; the numbers are simply several times too large.

The tell: a total that grows when you add a join that should only have added columns.

The fix

Fix 1 — Join on the key that makes the rows one-to-one — the transaction

SELECT m.risk_tier,
       ROUND(SUM(t.amount), 2) AS spend,
       COUNT(cb.chargeback_id) AS chargebacks
FROM merchants m
JOIN transactions t      ON t.merchant_id = m.merchant_id
LEFT JOIN chargebacks cb ON cb.txn_id = t.txn_id
GROUP BY m.risk_tier
ORDER BY m.risk_tier;
risk_tierspendchargebacks
high78,986.4310
low162,994.3944
medium118,985.0522

Fix 2 — Or aggregate each child table first, then join the totals

WITH spend AS (
  SELECT merchant_id, SUM(amount) AS spend
  FROM transactions GROUP BY merchant_id
), disputes AS (
  SELECT merchant_id, COUNT(*) AS chargebacks
  FROM chargebacks GROUP BY merchant_id
)
SELECT m.risk_tier,
       ROUND(SUM(s.spend), 2) AS spend,
       COALESCE(SUM(d.chargebacks), 0) AS chargebacks
FROM merchants m
JOIN spend s          ON s.merchant_id = m.merchant_id
LEFT JOIN disputes d  ON d.merchant_id = m.merchant_id
GROUP BY m.risk_tier
ORDER BY m.risk_tier;
risk_tierspendchargebacks
high78,986.4310
low162,994.3944
medium118,985.0522

Right answer: High-risk merchants have 78,986.43 of spend across 262 transactions, and 10 chargebacks — the wrong query overstated spend about threefold.

Rule of thumb. Before trusting a SUM or COUNT after a join, check that the join did not change the row count of the table you are summing.

Read next: SQL joins explained, with the row counts each join produces

Where this trap is tested

This trap is question 2 of our Capital One CodeSignal-style mock, and again in question 1 of our Capital One live SQL round mock — a timed practice screen on the same kind of data. The mock asks it in a different form, so this page does not give its answer away.

Practise it

Spend and Disputes Per Cardholder — Without the Fan-OutMedium · SQL practice questionDisputed Spend by Risk Tier and CategoryHard · SQL practice questionCross-Account Collusion — Shared Device FingerprintMedium · SQL practice questionSignup-Month Cohort SpendEasy · SQL practice question

Related SQL traps

Average of averageswhy AVG of group rates is wrongLEFT JOIN with a WHERE filterwhy it becomes an INNER JOINNOT IN with NULLwhy the query returns 0 rowsBETWEEN on timestampswhy the last day goes missing

Which traps would cost you in an interview?

Ten questions, no signup: a Skillmap across nine SQL skills and the one to fix first.

Take the readiness test