You write a query to total up each customer’s orders, and it runs fine — no error, no red text, just a result set:

SELECT customers.name, SUM(orders.amount) AS total_spent
FROM customers
JOIN orders ON orders.customer_id = customers.id
GROUP BY customers.name;

The totals come back too high. Not obviously broken — just off, in a way that’s easy to miss until someone downstream notices the numbers don’t match the invoice system. There’s no error here to read, which is exactly what makes this one harder than a syntax mistake: the query is completely valid SQL, and it’s doing precisely what you told it to. It’s just not doing what you meant.

What’s actually happening

A JOIN doesn’t attach related rows to each other one-to-one — it produces every combination of rows that satisfies the join condition. If a customer has three orders, that customer’s row from customers gets matched against all three rows from orders, and the join produces three separate rows in the result — one per order, each with the same customer name and id copied across all of them.

That’s correct and often exactly what you want. The trouble starts when there’s another join, or another table, contributing rows on top of that. Say each order also has multiple line items, and the query joins in a line_items table to look up something about the order:

SELECT customers.name, SUM(orders.amount) AS total_spent
FROM customers
JOIN orders ON orders.customer_id = customers.id
JOIN line_items ON line_items.order_id = orders.id
GROUP BY customers.name;

Now every order row gets duplicated once for each line item on that order — an order with four line items appears four times in the joined result, each copy carrying the same orders.amount. SUM(orders.amount) then adds that same amount in four times over, once per duplicate. Nothing in the query is malformed; the join is doing exactly what a join does. The aggregate is just summing over a result set that’s bigger than the one you were picturing — this is usually called a “fan-out,” and it’s the most common way a JOIN silently corrupts a total.

The fix, step by step

1. Confirm the row count, not just the total. Before trusting any SUM() or COUNT() after a join, run the join alone without the aggregate and check how many rows come back for a customer or order you can verify by hand. If a customer with 3 orders and no line-item join returns 3 rows, but adding the line_items join bumps that to 11, you’ve found your fan-out.

2. Identify which joined table is one-to-many relative to what you’re aggregating. In this example, orders is one-to-many with line_items — the join is multiplying orders, not customers. The fix has to happen at that join, not by changing the SUM() itself.

3. Aggregate the one-to-many side separately, before joining it up, so the multiplication never reaches your main total:

SELECT customers.name, SUM(order_totals.amount) AS total_spent
FROM customers
JOIN (
  SELECT customer_id, amount
  FROM orders
) AS order_totals ON order_totals.customer_id = customers.id
GROUP BY customers.name;

If you genuinely need data from line_items elsewhere in the query, aggregate it in its own subquery (one row per order) before joining that summary in — never join the raw, un-aggregated many-side table into a query that’s also summing a different column.

4. When you can’t restructure the joins, use DISTINCT or a pre-aggregated CTE as a stopgap — but treat it as a stopgap. SUM(DISTINCT orders.amount) will silently drop legitimate duplicate amounts (two different orders that happen to both be $50), so it trades one silent bug for another. The subquery approach in step 3 is the actual fix; DISTINCT is what you reach for only when you’re debugging in a hurry and plan to come back.

Two mistakes worth knowing about ahead of time

Trusting a query because it ran without an error. Fan-out joins are syntactically perfect SQL — there’s nothing for the database to reject. The only way to catch this class of bug is checking row counts and spot-verifying a total by hand against a case you already know the right answer for, not waiting for the database to complain.

Adding more joins to a query that already has an aggregate, without re-checking the totals. Every additional JOIN is a potential new fan-out, even if the query worked correctly before you added it. A query that correctly summed order totals yesterday can start silently inflating them today, the moment someone adds one more join to pull in an unrelated column — the aggregate function didn’t change, but the row set underneath it did.

A habit that prevents the confusion entirely

Any time a query joins more than two tables and also aggregates with SUM(), COUNT(), or AVG(), ask which table is on the “many” side of each join relative to what you’re totaling — and aggregate that side down to one row per key before it reaches the rest of the query, not after. Joins and aggregates are both doing exactly what they’re defined to do; the bug is always in which rows exist by the time the aggregate function runs over them, not in the aggregate function itself.