Adding a second LEFT JOIN doubled the revenue figure on a report. Explain what happened and write the correct query.
Two one-to-many joins from the same parent multiply against each other, so every payment row is counted once per order line. Aggregate each branch to one row per parent before joining, and reach for DISTINCT only where duplicate values are genuinely impossible.
What the interviewer is scoring
- Does the candidate identify the cartesian product between two child tables rather than blaming the join type
- Whether SUM DISTINCT is rejected as a fix, with the reason given
- That a predicate on the right-hand table of a LEFT JOIN belongs in ON rather than WHERE
- Can they explain why SUM over no rows returns NULL rather than zero
- Whether the engine's GROUP BY strictness is mentioned as something that differs between PostgreSQL and MySQL
Answer
The multiplication, seen on three rows
The join type is a red herring. What has happened is that orders has two independent one-to-many children, and joining both in one query produces every combination of them.
-- Wrong. Order 1 has 3 lines and 2 payments, so the join emits 6 rows:
-- each payment amount is summed 3 times and each line total twice.
SELECT o.id,
SUM(i.line_total) AS goods,
SUM(p.amount) AS paid
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.id
LEFT JOIN payments p ON p.order_id = o.id
GROUP BY o.id;
Three lines of £10 and two payments of £15 should report goods of £30 and paid of £30. The query reports £60 and £90. Both numbers are wrong, they are wrong by different factors, and neither is wrong by a constant you could divide out, because the factor for each column is the row count of the other child and that varies per order. A spot-check against a single-child order looks perfectly correct, which is why this survives review.
The rule to state is that you may join at most one one-to-many relationship per query and still aggregate safely. Beyond that, every additional collection multiplies the row count and every aggregate over the result is measuring the wrong set.
Aggregate first, then join
The fix is to collapse each child to one row per parent before it meets the other. Do the aggregation in a derived table, and the join becomes one-to-one.
SELECT o.id,
COALESCE(i.goods, 0) AS goods,
COALESCE(p.paid, 0) AS paid
FROM orders o
LEFT JOIN (SELECT order_id, SUM(line_total) AS goods
FROM order_items
GROUP BY order_id) i ON i.order_id = o.id
LEFT JOIN (SELECT order_id, SUM(amount) AS paid
FROM payments
GROUP BY order_id) p ON p.order_id = o.id;
COALESCE is not decoration. An order with no payments produces a NULL from the outer join, and paid of NULL propagates into every arithmetic expression downstream, so goods - paid silently becomes NULL rather than the full outstanding balance. This is the same reason SUM over an empty set returns NULL rather than 0, which surprises people: SUM ignores NULLs and is defined as NULL when it has nothing to add, while COUNT of the same empty set is 0.
Why DISTINCT is not the fix
The reflex fix is SUM(DISTINCT p.amount), and it appears to work on the example above because the two payments happen to be for different amounts. Give the order two payments of £15 each and the answer becomes £15. DISTINCT deduplicates values, and money legitimately repeats, so this converts a systematic error into an intermittent one that depends on your data. It is strictly worse, because the systematic version gets noticed.
COUNT(DISTINCT i.id) is a different matter and is genuinely correct, because a primary key does not repeat. That is the whole distinction: DISTINCT inside an aggregate is safe only over something unique by construction. Counting items and summing money in the same fanned-out query means one column can be rescued and the other cannot, which is a good sign you should not be in that query shape at all.
One neighbouring point belongs here because it appears in the same reports. COUNT(*) counts rows and COUNT(p.id) counts non-NULL values, and under an outer join they differ: an order with no payments contributes one row carrying a NULL p.id, so COUNT(*) says 1 and COUNT(p.id) says 0. The second is the one you want, and it is the idiomatic way to count matched children.
The outer join that quietly becomes an inner one
A related failure lands in the same reports. Filtering the right-hand table of a LEFT JOIN in the WHERE clause discards the very rows the outer join was written to preserve, because the unmatched rows carry NULL in that column and the predicate is not true for NULL.
-- Behaves as an INNER JOIN: orders with no settled payment vanish entirely.
FROM orders o
LEFT JOIN payments p ON p.order_id = o.id
WHERE p.status = 'settled'
-- Correct: the condition restricts which payments match, and orders with
-- no matching payment survive with NULLs.
FROM orders o
LEFT JOIN payments p ON p.order_id = o.id AND p.status = 'settled'
The exception is WHERE p.id IS NULL, which is deliberate and is the anti-join idiom for "orders with no settled payment". Recognising that IS NULL is the one predicate that works in the WHERE clause here, and understanding why, is a small thing that reads as fluency.
Two engine differences worth naming
Grouping strictness is the first. PostgreSQL requires every selected column to appear in GROUP BY or be functionally dependent on the grouped primary key, and rejects the query otherwise. MySQL enforces the same rule only when ONLY_FULL_GROUP_BY is enabled, which has been the default since 5.7.5; with it disabled it returns an arbitrary row's value for the ungrouped column, with no warning and no determinism. A query developed against a permissive MySQL and deployed against PostgreSQL fails loudly, which is the better outcome; the reverse direction is how a report ends up reporting one line's price as the order's price.
The second is NOT IN with NULLs, and it is not a difference so much as standard behaviour nobody expects. id NOT IN (SELECT order_id FROM payments) returns no rows at all if any order_id in that subquery is NULL, because id <> NULL evaluates to unknown rather than true and the conjunction can never be satisfied. NOT EXISTS has no such trap and usually plans as an anti-join, so prefer it unconditionally for this shape.
Likely follow-ups
- Rewrite it with lateral subqueries. When is that the better shape?
- How would you get goods, payments and refunds in one pass without three subqueries?
- The same report needs a count of orders with no payment at all. How?
- Why does NOT IN against a subquery containing a NULL return nothing?
Related questions
- Write a query returning the second-highest salary in each department, then tell me what it does when two people tie for the top.mediumAlso on sql-queries and null-handling3 min
- One machine's clock is ninety seconds ahead of the rest of the plant. Which of your numbers are wrong, and how would you have found out?hardAlso on joins6 min
- Design the home feed for a social network.hardAlso on fan-out8 min
- Your test still reaches the network even though you patched the client. Talk me through fixture scope, parametrisation, and where a patch has to point.mediumSame kind of round: concept4 min
- This form marks invalid fields with red text and a red border. What has to change?mediumSame kind of round: concept4 min
- How is `this` determined in JavaScript, and why does a method lose it when you pass it as a callback?mediumSame kind of round: concept4 min
- How do you remove elements from a collection while iterating it, and what makes an iterator fail-fast?mediumSame kind of round: concept5 min
- How would you rewrite a row-by-row pandas transformation, and what is SettingWithCopyWarning telling you?mediumSame kind of round: coding5 min