In SQL, how do you aggregate two one-to-many child tables in one query without cross-inflating totals?
answer
- two children multiply each other
- the row count is a product, not a sum
- reduce each side to one row per parent
- one aggregation per grain, then join
- CTE per child, LEFT JOIN the summaries
basics
~20 sAggregate each child separately first — one derived table or CTE per child, grouped by the parent key — then LEFT JOIN those one-row-per-parent summaries back to the parent. Joining both children directly multiplies their rows together.
solid answer
~40 sJoining a parent to two one-to-many children in the same `FROM` clause produces the **product** of the two child row counts: an order with 2 payments and 3 shipments yields 6 rows, so the payment total is tripled and the shipment count doubled. Each child inflates the other, which is why the numbers look plausible but are wrong by a different factor per row. The portable fix is one aggregation per grain: build a CTE (or derived table) per child that groups by `order_id` and produces exactly one row per order, then `LEFT JOIN` each summary to `orders`. Because each summary is unique on the join key, nothing multiplies. Use `LEFT JOIN` plus `COALESCE(x, 0)` so orders with no payments or no shipments still appear with zeros instead of vanishing or showing NULL.
code
sql · 8 lines-- one order with 2 payments and 3 shipments -> 6 joined rows
SELECT o.order_id,
SUM(p.amount) AS paid, -- 3x too high
COUNT(s.shipment_id) AS shipments -- 2x too high
FROM orders o
JOIN payments p ON p.order_id = o.order_id
JOIN shipments s ON s.order_id = o.order_id
GROUP BY o.order_id;go deeper
Recall the shape rather than the theory: when a query touches a parent and two detail tables, aggregate each detail table on its own first, then join the results. Expect to write the CTE version on a whiteboard.
Explain the product rule — 2 payments times 3 shipments equals 6 rows — and why each measure is inflated by the other child's count. Then produce the CTE-per-child rewrite including LEFT JOIN and COALESCE.
Demonstrate the discipline behind it: name the grain of every derived table, keep each child's filters inside its own summary, and validate with control totals before a number reaches a dashboard.
Set the house rule that reporting queries aggregate to a single declared grain per subquery and never mix detail tables in one FROM clause, and decide where that layer lives — views, a semantic layer, or a modelled mart.
## The bug: children multiply each other Consider `orders`, `payments` and `shipments`, each child holding several rows per order. The natural-looking query is: ```sql SELECT o.order_id, SUM(p.amount) AS paid, COUNT(s.shipment_id) AS shipments FROM orders o JOIN payments p ON p.order_id = o.order_id JOIN shipments s ON s.order_id = o.order_id GROUP BY o.order_id; ``` For an order with 2 payments and 3 shipments, the first join yields 2 rows, and the second pairs each of those with 3 shipments — 6 rows. Every payment now appears 3 times and every shipment 2 times, so `paid` is tripled and `shipments` doubled. Nothing errors, nothing warns; the report is simply wrong, and wrong by a *different* multiplier for every order, so no constant division repairs it. The general rule: joining `n` independent one-to-many children multiplies the parent row by the product of their match counts. Two children are enough to make the result unusable, and the distortion of each measure is driven by the *other* child's cardinality — the least intuitive part of the bug. ## The fix: one aggregation per grain A grain is the level a row describes: one row per order, one row per payment, one row per shipment. Mixing grains in one `FROM` clause is what breaks. So reduce each child to the parent's grain *first*, then join grain-to-grain: ```sql WITH pay AS ( SELECT order_id, SUM(amount) AS paid FROM payments GROUP BY order_id ), shp AS ( SELECT order_id, COUNT(*) AS shipments FROM shipments GROUP BY order_id ) SELECT o.order_id, COALESCE(pay.paid, 0) AS paid, COALESCE(shp.shipments, 0) AS shipments FROM orders o LEFT JOIN pay ON pay.order_id = o.order_id LEFT JOIN shp ON shp.order_id = o.order_id; ``` Each CTE is unique on `order_id` — `GROUP BY order_id` guarantees it — so both joins are one-to-one and no row is ever duplicated. The outer query needs no `GROUP BY` at all, which is itself a good signal that the grains now line up. Derived tables in the `FROM` clause work identically; the `WITH` form is just easier to read and to extend with a third child. ## Why LEFT JOIN and COALESCE An inner join to the summaries would silently drop orders that have no payments or no shipments, turning a correctness bug into a coverage bug. `LEFT JOIN` keeps every order and NULL-extends the missing summary columns. `COALESCE(pay.paid, 0)` then turns "no payments" into 0 rather than NULL, which matters because NULL propagates through arithmetic: `COALESCE(pay.paid, 0) - COALESCE(refund.amount, 0)` gives a number, while the un-coalesced version gives NULL for any order missing either side. ## Filters belong inside the summary If only settled payments count, put the predicate inside the CTE (`WHERE status = 'SETTLED'`), not in the outer `WHERE`. Filtering after a `LEFT JOIN` on a column from the right side turns the outer join back into an inner one and drops orders with no settled payment. Keeping every child's own predicates inside that child's summary is what makes this pattern compose: each CTE is independently readable and independently testable — you can run it alone and check that it returns one row per order. ## When you can skip the CTEs Two narrower situations do not need the full pattern. First, if only one child is joined and you only need a **count of parents**, `COUNT(DISTINCT o.order_id)` deduplicates by primary key. Second, if a child is genuinely one-to-at-most-one (enforced by a unique constraint on the join key), joining it directly is safe — there is no fan-out to prevent. The dangerous case is assuming uniqueness that the schema does not enforce; a key that happens to be unique today will not be after next month's data. ## What does not work `SELECT DISTINCT` over the six-row product does not help: the rows differ in payment and shipment columns, so none are removed, and even when it appears to work it destroys legitimately duplicate detail rows. `SUM(DISTINCT p.amount)` deduplicates values, so two genuine payments of the same size collapse into one. Dividing a measure by the other child's row count is arithmetic patching that breaks the moment a parent has zero children on one side. ## Interview framing Say the word *grain*, state the product rule (2 × 3 = 6 rows), and write the CTE-per-child rewrite. Add the `LEFT JOIN` plus `COALESCE` detail unprompted — that is the part that separates someone who has read about fan-out from someone who has shipped a report.
- Why LEFT JOIN the pre-aggregated CTEs rather than INNER JOIN them?An inner join drops orders that have no rows in that child at all — an order with no shipments would disappear from the report entirely. `LEFT JOIN` keeps every order and NULL-extends the missing summary columns, and `COALESCE(x, 0)` turns those NULLs into zeros so later arithmetic still produces numbers rather than NULL.
- Where do you put a filter such as 'only settled payments'?Inside that child's CTE, next to its `GROUP BY`. Putting it in the outer `WHERE` references a column from the right side of a `LEFT JOIN`, which discards orders with no settled payment and quietly converts the outer join back into an inner one.
- When is joining a child directly still safe?When the join key is unique on the child side — enforced by a primary key or unique constraint, not merely true in today's data. Then the join is one-to-at-most-one and no parent row is duplicated. If uniqueness is only an assumption, a single new row turns a correct report into a wrong one.
- How do you verify the rewrite actually fixed the numbers?Check each summary in isolation: it must return one row per parent key, which `GROUP BY order_id` guarantees. Then compare a control total — `SELECT SUM(amount) FROM payments` should equal the sum of the `paid` column across the final result, and the final row count should equal the row count of `orders`.
saying these in an interview costs you the question
- Adds SELECT DISTINCT instead of fixing the grain
- Thinks two child joins add rows rather than multiply them
- Divides the total by the other child's row count
- Uses INNER JOIN to the summaries and loses childless parents
- Puts the child's filter in the outer WHERE after a LEFT JOIN