2.12 Join pitfalls¶
Concept¶
A query with a wrong join usually doesn't fail — it returns a number that looks fine and is wrong. These mistakes are the same in every SQL engine: Trino, Postgres, Spark, Snowflake, BigQuery. Learn to spot them once and you'll spot them everywhere.
| Pitfall | What goes wrong | The fix |
|---|---|---|
| Fan-out | a sum is counted 2–3× | aggregate to the grain you need first, then join |
COUNT(*) after a join |
counts lines, not orders | COUNT(DISTINCT …) |
| Inner join drops rows | customers without orders vanish | LEFT JOIN + COALESCE |
WHERE on a left join |
the LEFT JOIN silently becomes an inner join |
put the condition in ON |
NOT IN with a NULL |
returns no rows at all | NOT EXISTS |
| Keys of different types | error, or wrong matches | CAST one side |
Two words used below:
- Grain — what one row means.
orders: one row per order.order_items: one row per order line.payments: one row per order. - Fan-out — joining to a table with a finer grain repeats every row of the coarser table, as you saw in Joins & aggregations.
Lab¶
Run these in SQLPad on the Lakehouse (Trino) — <your username> connection, like the rest of this unit.
ShopFlow's data is generated at random, so your numbers differ a little from the examples — the pattern is what matters.
1 · Fan-out: a sum counted several times¶
Question: how much was paid, and how many items were sold, per channel? The money is in
payments (one row per order), the items in order_items (several rows per order). Join both:
-- WRONG: every payment is repeated once per order line
SELECT o.channel,
SUM(p.amount) AS paid,
SUM(oi.quantity) AS items
FROM orders o
JOIN payments p ON p.order_id = o.order_id
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.channel
ORDER BY o.channel;
Example output:
| channel | paid | items |
|---|---|---|
| app | 59511692.29 | 75882 |
| marketplace | 31115591.56 | 39610 |
| web | 90165547.68 | 115788 |
| wholesale | 30118734.96 | 38729 |
items is right; paid is about 3× too high. An order with 3 lines has 3 joined rows — each
carrying the same payment, and SUM adds it 3 times.
The fix: bring order_items to the grain of an order first, then join — one row meets one
row, nothing is repeated:
-- RIGHT: aggregate order_items to one row per order, then join
SELECT o.channel,
SUM(p.amount) AS paid,
SUM(i.items) AS items
FROM orders o
JOIN payments p ON p.order_id = o.order_id
JOIN (SELECT order_id, SUM(quantity) AS items
FROM order_items
GROUP BY order_id) i ON i.order_id = o.order_id
GROUP BY o.channel
ORDER BY o.channel;
JOIN (SELECT … GROUP BY order_id) i— a subquery inFROM: its result is used like a table, namedi. It has one row per order, the same grain aspayments.
Example output:
| channel | paid | items |
|---|---|---|
| app | 19812128.58 | 75882 |
| marketplace | 10327858.62 | 39610 |
| web | 30099564.22 | 115788 |
| wholesale | 9981281.32 | 38729 |
Same items, and now paid matches payments on its own. Check: whenever a sum looks
"a bit high", count the rows per key on each side of the join.
2 · COUNT(*) after a join counts lines¶
SELECT COUNT(*) AS rows_,
COUNT(DISTINCT o.order_id) AS orders
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id;
Example output:
| rows_ | orders |
|---|---|
| 100000 | 40000 |
After the join there's one row per line. COUNT(*) counts rows; to count orders, count each
order once with COUNT(DISTINCT o.order_id).
3 · An inner join drops rows without a match¶
How many customers never ordered? A plain JOIN can't answer it — customers without orders
aren't in its result. A LEFT JOIN keeps them, with NULL on the right side:
SELECT COUNT(*) AS customers,
count_if(COALESCE(o.orders, 0) = 0) AS never_ordered
FROM customers c
LEFT JOIN (SELECT customer_id, COUNT(*) AS orders
FROM orders
GROUP BY customer_id) o
ON o.customer_id = c.customer_id;
COALESCE(o.orders, 0)—0when there's no match (o.ordersisNULL).count_if(condition)— counts the rows where the condition is true; the same asSUM(CASE WHEN … THEN 1 ELSE 0 END).
Example output:
| customers | never_ordered |
|---|---|
| 6000 | 3515 |
With an inner JOIN instead, customers would be 2485 — more than half the customers gone,
without an error.
4 · WHERE on a left join turns it into an inner join¶
Every customer, with their cancelled orders if they have any. Two ways to write it look almost the same:
-- WRONG: the filter is in WHERE
SELECT COUNT(DISTINCT c.customer_id) AS customers
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = 'cancelled';
-- RIGHT: the filter is part of the join
SELECT COUNT(DISTINCT c.customer_id) AS customers
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
AND o.status = 'cancelled';
Example: 1300 vs 6000.
WHEREruns after the join. Customers without a cancelled order haveo.status = NULL,NULL = 'cancelled'isn't true, soWHEREthrows them away — theLEFT JOINbehaves like an inner join.ON … AND …filters only which orders join. Every customer stays; those with no cancelled order getNULLs.
Rule: a condition on the right table of a LEFT JOIN goes in ON, not WHERE.
5 · NOT IN and a NULL¶
No rows. "1 is not 2, not 3, and not unknown" — SQL can't be sure 1 isn't the unknown value,
so the answer is unknown, and the row is dropped. If the list in NOT IN (SELECT …) contains
a single NULL, the query returns nothing.
Use NOT EXISTS instead — it ignores NULLs:
SELECT COUNT(*) AS never_ordered
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);
NOT EXISTS (subquery)— true when the subquery returns no rows for this customer.SELECT 1because only "is there a row?" matters, not its columns.
Example output: 3515 — the same as step 3.
6 · Join keys of different types¶
Keys from files (CSV, JSON, an API) often arrive as text: '42', not 42. Comparing a
number with text fails in Trino:
SELECT COUNT(*)
FROM orders o
JOIN (SELECT '42' AS customer_id) x -- a text key, like from a CSV
ON o.customer_id = x.customer_id;
An error like Cannot apply operator: integer = varchar(2). Cast one side so both have the
same type:
SELECT COUNT(*)
FROM orders o
JOIN (SELECT '42' AS customer_id) x
ON o.customer_id = CAST(x.customer_id AS integer);
CAST(value AS type)— converts a value to another type.
Example output: 83 — customer 42's orders.
Engines differ here: Trino and Postgres refuse to compare the types; others (Snowflake, MySQL) convert silently and can fail later or match the wrong rows. Casting yourself works everywhere.
Check yourself¶
- A dashboard's payment total jumped 3× after someone joined
order_itemsto add an item count. Why, and what's the fix? (Fan-out — each payment repeated per line. Aggregateorder_itemsper order first, then join.) LEFT JOIN orders … WHERE o.status = 'shipped'returns fewer customers than thecustomerstable has. Why? (TheWHEREdrops customers without a shipped order — move the condition intoON.)WHERE id NOT IN (SELECT id FROM other)returns nothing, though you know some ids are missing. What's the likely cause? (other.idcontains aNULL. UseNOT EXISTS.)