open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 02 · 06

Join traps and row explosions

Joining two one-to-many children to a parent multiplies rows and silently doubles your SUM and COUNT — the fan-out bug. The fix is to aggregate each child separately first. Plus missing conditions, duplicate keys, and join order.

SQL Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A finance dashboard reported $2.4M in monthly revenue. The accounting system said $1.2M. Exactly double. The bug was a single query that joined orders to both order_items and payments in one statement and summed the order total. Each order had ~2 payments, so every order row was duplicated, and the SUM(o.total) counted each order’s amount once per payment row. No error, no warning — a join silently doubled a number the whole company trusted. This is the fan-out, and it is the most expensive join bug there is because the query runs fine.

The fan-out: two children, multiplied rows

Recall lesson 1: a join is a product, and the row count multiplies. A fan-out is that multiplication striking where you didn’t expect it. Take one parent (orders) with two one-to-many children: order_items (the line items) and payments (installments). Join all three in one query:

-- WRONG: revenue is doubled (or worse)
SELECT SUM(o.total) AS revenue,
       SUM(oi.quantity) AS units
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN payments    p  ON p.order_id  = o.id;

If an order has 3 items and 2 payments, the join produces 3 × 2 = 6 rows for that single order — every item paired with every payment, because both children join on order_id independently. Now SUM(o.total) adds that order’s total 6 times, and SUM(oi.quantity) counts each item 2 times (once per payment). The order’s amount didn’t change; the number of rows carrying it did. The two children fanned out against each other — a Cartesian product within each order’s group.

The fix: pre-aggregate each child before joining

The rule is blunt: never join two independent one-to-many children to a parent and then aggregate. Collapse each child to one row per parent first — in a subquery or CTE — then join those pre-aggregated results, which are now one-to-one with the parent and cannot fan out:

-- RIGHT: each child aggregated to one row per order, then joined
SELECT o.id,
       o.total,
       items.units,
       pays.paid
FROM orders o
LEFT JOIN (
  SELECT order_id, SUM(quantity) AS units
  FROM order_items GROUP BY order_id
) items ON items.order_id = o.id
LEFT JOIN (
  SELECT order_id, SUM(amount) AS paid
  FROM payments GROUP BY order_id
) pays ON pays.order_id = o.id;

Each subquery returns at most one row per order_id, so neither join multiplies. SUM(o.total) over this is now correct. The pattern generalizes to any number of children: aggregate each in its own CTE/subquery, then join the one-row-per-parent summaries. (LATERAL from the last lesson is another way to compute these per-parent aggregates inline.)

For a quick count you can sometimes patch a fan-out with COUNT(DISTINCT oi.id) instead of COUNT(*) — distinct counting ignores the duplicated rows. But SUM(DISTINCT o.total) is a trap: two different orders can legitimately have the same total, and DISTINCT would collapse them. COUNT(DISTINCT) rescues counts; only pre-aggregation reliably rescues sums.

The other traps: missing conditions, duplicate keys, join order

The fan-out is the subtle one, but three blunter traps share the unit. When you review a query that joins several tables, ask: does each join key constrain the relationship, is it unique on at least one side, and are there multiple independent children being joined to the same parent?

  • Missing join condition → the accidental cross join from lesson 1. A forgotten ON, or an ON that doesn’t actually relate the tables, yields |A| × |B| rows — an instant explosion, not a quiet doubling.
  • Non-unique join keys → even an “innocent” join duplicates rows if the key isn’t unique on the side you assumed was unique. Joining orders to users on email instead of id will fan out if two users share an email. Always join on a key that is unique on at least one side, or know exactly why it isn’t.
  • Join order and the planner → you write joins left to right, but the planner reorders them freely to minimize cost; the result is identical regardless of written order (for inner joins). So don’t hand-tune join order for correctness or speed — fix the predicates and statistics and let the planner choose. (Outer joins constrain reordering; the databases execution-plans chapter covers when.)
Common mistake

The reason fan-out is so dangerous is that it is invisible in small data. In dev, an order has one item and one payment, so 1 × 1 = 1 — the query looks correct and ships. In production, orders have many items and installment payments, the multiplication kicks in, and the dashboard is silently 2× or 5× high. The defense is a habit, not a tool: whenever a query joins more than one child table to a parent and then aggregates, stop and pre-aggregate each child. Sanity-check by comparing the joined row count to the parent row count — if joining added rows, any per-parent aggregate is now wrong.

Quiz

An order has 3 items and 2 payments. You SELECT SUM(o.total) joining orders to BOTH order_items and payments. The true total is $100. What does the query report?

Quiz

What is the reliable fix for a fan-out when summing a parent value across multiple child tables?

Order the steps

Order the steps to safely report revenue and units across orders, items, and payments:

  1. 1 Notice the query joins TWO one-to-many children (items, payments) to orders and aggregates
  2. 2 Recognize this fans out: rows = items × payments per order, so sums over-count
  3. 3 Aggregate order_items to one row per order_id (SUM quantity) in a subquery/CTE
  4. 4 Aggregate payments to one row per order_id (SUM amount) in a separate subquery/CTE
  5. 5 Join the two one-row-per-order summaries to orders (now one-to-one, no fan-out)
  6. 6 Verify the joined row count equals the orders row count
Recall before you leave
  1. 01
    Explain the fan-out bug: why does joining orders to both order_items and payments double the revenue?
  2. 02
    What is the reliable fix, and why is SUM(DISTINCT) not it?
  3. 03
    Should you hand-order your joins for performance, and what causes duplicate rows from a 'normal' join?
Recap

The fan-out is the unit’s most expensive bug because the query succeeds while the number is wrong. Joining two independent one-to-many children to one parent forms a Cartesian product within each parent group: i items × p payments = i × p rows per order, so SUM(o.total) adds the order’s amount i × p times and a finance dashboard silently doubles. The reliable fix is pre-aggregation: collapse each child to one row per parent in its own subquery or CTE, then join those one-to-one summaries (or compute them with LATERAL); COUNT(DISTINCT id) can patch a count but SUM(DISTINCT) cannot patch a sum, since equal values collapse. The unit’s other traps are blunter: a missing or too-loose ON is the accidental cross join from lesson 1; a non-unique join key duplicates rows the same way (join on a key unique on at least one side); and join order is the planner’s job, not yours — fix predicates and statistics, not the written sequence. The closing habit: any time a query joins more than one child to a parent and aggregates, compare the joined row count to the parent count — if the join added rows, every per-parent aggregate is already wrong. Now when you see a query join multiple child tables to a parent and then sum a column, you’ll check whether the row count multiplied — because a silent 2× is far more dangerous than an error that fails loudly.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.