Skip to content

Intermediate: combining and changing data · JOINs · topic 25 of 62 · 35 min

Join pitfalls and how to avoid them

Explanation

Most join bugs return wrong numbers, not errors. Joining two one-to-many tables at once duplicates rows and inflates SUM and COUNT (fan-out). A filter on the right table in WHERE turns a LEFT JOIN into an inner join. Wrong keys, NULL keys and NATURAL JOIN cause quiet mistakes too.

Why join bugs are dangerous

A syntax error stops you at once. A join bug does not: the query runs and returns a neat table of wrong numbers, which then goes into a report. This topic collects the usual traps, how to spot each, and how to fix it.

1. Fan-out: rows multiply, totals inflate

When one row matches many rows, it is repeated once per match. That is correct and expected. The trouble starts when you add up a column of the "one" side, or join two different "many" tables to the same row. This is called fan-out.

Say each order can have several items and also several payments (a customer may pay part by UPI and the rest by card). Order 101 has 2 items and 2 payments. Joining both gives 2 × 2 = 4 rows for order 101, and every sum doubles.

sql
SELECT o.id, SUM(oi.qty * oi.price) AS items_total, SUM(p.amount) AS paid
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.id
JOIN payments AS p     ON p.order_id = o.id
GROUP BY o.id;
-- 101 | 3560 | 3560   <- real values: 1780 and 1780
-- 103 | 2248 | 4496   <- 2 items, so the one payment is counted twice
Wrong, and nothing warns you.

The fix: aggregate each "many" table first, down to one row per order, and then join the small results. A subquery in FROM (module 6 explains it fully) does this neatly.

sql
SELECT i.order_id, i.items_total, p.paid
FROM (SELECT order_id, SUM(qty * price) AS items_total FROM order_items GROUP BY order_id) AS i
JOIN (SELECT order_id, SUM(amount) AS paid FROM payments GROUP BY order_id) AS p
  ON p.order_id = i.order_id;
-- 101 | 1780 | 1780
-- 103 | 2248 | 2248
-- 105 | 1770 | 1000   <- part-paid, now clearly visible

A milder fan-out: customers → orders → order_items, then COUNT(o.id). Each order is counted once per line, so Asha shows 5 orders instead of 3. COUNT(DISTINCT o.id) fixes counts, but there is no safe "SUM DISTINCT" for money; two different lines can have the same amount. For sums, aggregate first.

Tip: Quick test for fan-out: run the joined query without GROUP BY and count the rows for one key you know, such as order 101. If you see more rows than you expected, the totals are wrong.

2. ON versus WHERE in a LEFT JOIN

You want every customer with their number of Delivered orders, zeros included. Putting the status filter in WHERE runs it after the join. The NULL rows of customers with no Delivered orders fail o.status = 'Delivered' (NULL is not true), so they are removed, and the LEFT JOIN behaves like an inner join.

sql
-- Wrong: only Asha 2 and Ravi 2 come back
SELECT c.name, COUNT(o.id) AS delivered
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE o.status = 'Delivered'
GROUP BY c.id, c.name;

-- Right: the filter decides which orders match; every customer stays
SELECT c.name, COUNT(o.id) AS delivered
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id AND o.status = 'Delivered'
GROUP BY c.id, c.name;
-- Asha 2, Ravi 2, Priya 0, Imran 0, Sneha 0, Arjun 0

The rule: conditions on the right table of a LEFT JOIN go in ON; conditions on the left table (which rows to report on) go in WHERE. For inner joins it makes no difference.

3. Ambiguous and wrong keys

"ambiguous column name: id" means two tables have that column; add the alias prefix. Worse is a wrong key that runs: employees JOIN departments ON d.id = e.id pairs employee 1 with department 1, employee 2 with department 2, and so on. Rahul Sharma lands in Sales. Always join a foreign key to the key it references.

4. NULL keys never match

NULL = NULL is not true, so rows with a NULL join key never match, even with each other. Deepak (dept_id NULL) disappears from an inner join. If such rows matter, use a LEFT JOIN, and show them with COALESCE(d.name, 'No department').

5. USING and NATURAL JOIN

When both tables name the key the same, JOIN payments USING (order_id) is a short form of ON order_items.order_id = payments.order_id, and the column appears once in SELECT *. It is fine to use. NATURAL JOIN goes further and joins on every column with the same name, automatically. Avoid it: customers NATURAL JOIN orders matches on id (customer id = order id) and returns 0 rows, and products NATURAL JOIN order_items matches only on price. If someone later adds a column such as updated_at to both tables, the join silently changes.

Common mistakes

  • Summing after joining two one-to-many tables. Aggregate each one first, then join.
  • Filtering the right table of a LEFT JOIN in WHERE. Move the condition into ON.
  • Using DISTINCT to hide duplicate rows. Find which join fans out instead.
  • Using NATURAL JOIN. Write the ON (or USING) explicitly.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Join pitfalls and how to avoid them · 1/7

Join pitfalls

Join bugs rarely cause errors. They give believable but wrong numbers. Let us learn the usual traps.

Prefer a video by a teacher? Find free videos on “sql join mistakes fan out duplicate rows left join where vs on” on YouTube ↗

In real life: A finance report that did not add up

A common story in analytics teams: a revenue dashboard shows more money than the bank received. The cause is usually a fan-out, for example orders joined to both shipments and payments, so an order with two shipments counts its payment twice. Reconciling with an independent number (bank statements, a simple per-table total) catches it, and the fix is to aggregate each table to one row per order before joining.

sql
-- A sanity check: totals from the raw tables, no joins
SELECT (SELECT SUM(qty * price) FROM order_items) AS items_total,
       (SELECT SUM(amount) FROM payments)          AS paid;
Compare these with the sums of your joined report. If the report is higher, look for fan-out.

Practice (0/3 done)

Task 1 · Code · SQL

A colleague joined customers → orders → order_items and wrote COUNT(o.id) AS orders, which gives Asha 5 orders instead of 3. Fix it. For each customer who has orders, return name, orders (the number of different orders) and units (the total qty of all their order lines).

Task 2 · Code · SQL

This database also has payments(id, order_id, method, amount); an order can be paid in parts. The starter joins order_items and payments together and doubles some totals. Fix the fan-out by adding up each table per order first. For every order that has both items and payments, return order_id, items_total (sum of qty * price) and paid (sum of amount).

Task 3 · Code · SQL

Show every customer with the number of their Delivered orders, including customers with 0. Return name and delivered. (Hint: where does the status condition belong?)

Quick quiz

Question 1

1.An order has 3 items and 2 payments. You join orders to both tables and SUM(payments.amount). By how much is that order's payment total multiplied?

Question 2

2.customers LEFT JOIN orders ... WHERE o.status = 'Delivered'. What happens to customers with no Delivered orders?

Question 3

3.Why avoid NATURAL JOIN?

Question 4

4.Which is the safe way to fix inflated SUMs from fan-out?

Question 5

5.JOIN payments USING (order_id) means...

Read more (free)

Before this, learn

This topic builds on these. If something here feels new, open them first.

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

An order has 3 items and 2 payments. You join orders to both tables and SUM(payments.amount). By how much is that order's payment total multiplied?

Answer: 3

Each payment row is repeated once per item row, so each payment is counted 3 times.

customers LEFT JOIN orders ... WHERE o.status = 'Delivered'. What happens to customers with no Delivered orders?

Answer: They are removed, because NULL = 'Delivered' is not true

Move the condition into ON to keep them.

Why avoid NATURAL JOIN?

Answer: It joins on every column with the same name, which can be the wrong columns and can change when columns are added

customers NATURAL JOIN orders matches on id and returns 0 rows.

Which is the safe way to fix inflated SUMs from fan-out?

Answer: Aggregate each one-to-many table to one row per key first, then join

SUM(DISTINCT) wrongly drops equal amounts; aggregating first is exact.

JOIN payments USING (order_id) means...

Answer: Join on the order_id column that both tables share

USING names the shared column explicitly; it is an inner join unless written LEFT JOIN ... USING.

You join orders and customers and get the error "ambiguous column name: id". What is the fix?Related

Answer: Write o.id or c.id so SQL knows which table you mean

Both tables have an id column. Prefix it with the table name or alias.

From Joins: combining tables

In the anti-join products LEFT JOIN order_items ... WHERE ? IS NULL, which column is the safest to test?Related

Answer: oi.product_id, the join column of the right table

The right table's join key is NULL only when there was no match. p.id is never NULL, and qty could be NULL in a real row.

From LEFT, RIGHT and FULL OUTER JOIN

Employees has 11 rows; one has dept_id NULL. How many rows does employees INNER JOIN departments ON d.id = e.dept_id return (every other employee has a valid department)?Related

Answer: 10

The NULL key matches nothing, so that employee is dropped.

From Why joins, and INNER JOIN

customers LEFT JOIN orders ... JOIN order_items (an inner join). What happens to Arjun, who has no orders?Related

Answer: He is dropped by the inner join, because his NULL order id matches no order line

A later inner join removes the unmatched rows the LEFT JOIN kept. Use LEFT JOIN for the later joins too.

From Joining three or more tables

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. This is the last topic of “JOINs”. The module exam comes next.
  2. Module 5 exam: JOINs · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, Subqueries, CTEs and set operations. 62 topics left in the course before the final exam.

← Previous: Self joins and cross joins