A concrete example: 2 orders, 3 payments, 3 shipments
The lab below uses one parent table and two child tables. orders.order_id is unique, while payments.order_id and shipments.order_id both repeat for O-1001. Walk the steps and watch the returned row count, the distinct order count and SUM(order_amount) change together.
Each row is one order with its own order_amount.
Each row is one payment; nothing prevents several payments per order.
Each row is one shipment; joining both children on the same key crosses them.
No rows yet: the query has not joined the tables.
| order_id | amount |
|---|---|
| O-1001 | $300 |
| O-1002 | $500 |
| payment_id | order_id | paid_amount | paid_on |
|---|---|---|---|
| P-01 | O-1001 | $100 | 2026-02-01 |
| P-02 | O-1001 | $100 | 2026-02-01 |
| P-03 | O-1002 | $200 | 2026-02-03 |
| shipment_id | order_id | carrier |
|---|---|---|
| S-01 | O-1001 | DHL |
| S-02 | O-1001 | UPS |
| S-03 | O-1002 | DHL |
Why the duplication happens
A JOIN is not a "paste columns together" operation. It is a matching operation: for every pair of rows where the ON condition is true, the database emits one output row. The database does not know that order_amount describes an order rather than a payment.
One-to-many: the parent is copied
When the parent key is unique and the child key repeats, one parent row matches several child rows. The parent columns are copied into each match:
-- orders: 1 row for O-1001, order_amount = 300
-- payments: 2 rows for O-1001
SELECT *
FROM orders AS o
JOIN payments AS p ON p.order_id = o.order_id
WHERE o.order_id = 'O-1001';
-- 2 rows: order_amount = 300 appears twiceMany-to-many: two repeating keys multiply
When both sides repeat the same key — or you join two child tables on that key — the matches cross each other. In the lab, joining payments and shipments onorder_id gives O-1001 2 × 2 = 4 rows, and its order amount is copied four times.
JOIN never reports an error for this. The only way to see it is to compare the row count, the distinct key count and the measure you are aggregating.
How duplication shows up in your numbers
The tell is always the same: a measure whose grain is finer than the result row's grain gets summed once per matching row.
| Expression | Result | Why |
|---|---|---|
COUNT(*) | 3 (or 5 with shipments) | Counts joined rows, not orders |
COUNT(DISTINCT order_id) | 2 | Counts the real orders |
SUM(order_amount) | $1,100 (or $1,700) | Adds the copied order amount |
| Actual order total | $800 | 300 + 500, one row per order |
A single inflated number is easy to miss in a dashboard. Compare the result row count with the row count of the table whose grain you meant to report: if they differ, stop and check the join key before trusting any aggregate.
Why DISTINCT is usually the wrong fix
DISTINCT removes duplicate result rows. It does not restore the grain you intended, and it often does not remove anything at all.
SELECT DISTINCT *still returns every joined row when any selected column differs — for examplepayment_idis different for the twoO-1001payments.- Removing columns until
DISTINCTcollapses rows silently changes the question. The query no longer returns the payment detail you selected it for, and two genuinely identical payments collapse into one row. SUM(DISTINCT order_amount)is not a repair. If two orders legitimately share the same amount, the second one disappears:SELECT SUM(DISTINCT order_amount) FROM ordersreturns 300 for two $300 orders.
If the business question is "how many distinct (order, payment) pairs exist", thenDISTINCT or GROUP BY is the right tool. It is not a way to make a measure safe while keeping a wrong join.
Fixes that keep the grain correct
Pre-aggregate the many side, then join
- Aggregate each child table to one row per order first.
- The join returns one row per order and
SUM(order_amount)is safe. - Child totals such as
payments_totalstay separate measures.
Use EXISTS when you only filter
EXISTStests the child table without adding columns.- The parent grain never changes, so no measure is copied.
- Prefer
NOT EXISTSover a join plusIS NULLanti-join.
Join on the key that is actually unique
- If the real grain is (order_id, payment_id), include both columns.
- Do not assume a column named like an id is unique in every table.
- Confirm uniqueness with
COUNT(*)versusCOUNT(DISTINCT key).
Aggregate at the target grain after the join
- Add
GROUP BY order_idwhen the output should be one row per order. - If multiple child tables fan out, group each child in its own CTE first.
- A
GROUP BYalone cannot repair measures copied by a wrong join.
The right fix depends on the target grain. Decide what one output row must represent, then choose the join shape that produces exactly that — this is the same decision described indata warehouse grain.
Edge cases and trade-offs
- Intentional fan-out. A line-item fact table is supposed to have one row per line item. Fan-out is a bug only when the result row's grain is finer than the measure you aggregate.
LEFT JOINwith no match. An unmatched parent produces one row withNULLchild columns; it does not duplicate, but later aggregations must handleNULL.- Two child tables on the same key. Joining both in one query creates a cross product per key. Aggregate each child to the target grain in separate CTEs and join the CTEs.
- Pre-aggregation cost. Pre-aggregating scans the child table and can be more expensive than sorting for
DISTINCT, but it produces a correct result. Measure both on realistic data. - Data model choice. If duplicated joins keep appearing, the model may be missing a key or a bridge table rather than a query hint.