TsundereSQL

SQL problems / Why did my JOIN duplicate rows?

SQL problem

Why did my JOIN duplicate rows?

Nothing in your tables was duplicated. A JOIN emits one row for every pair of rows that satisfies the ON condition, and one of your left rows has more than one partner. Here is the smallest version of that, with the output it produces and the total it breaks.

The symptom

My orders table has three rows. I joined it to shipments and got three rows back, so I nearly shipped it. Then I looked: order 801 is in there twice and order 803 is gone. My revenue total came out higher than the sum of the orders table. I did not change any data.

Two separate things happened in that one query, and the row count hid both of them. One order was matched twice because it has two shipments. One order disappeared because it has none. The totals that come out of that join are the part that reaches a dashboard.

The two tables

orders
order_idcustomer_idtotal
80110125
8022080
80330300
shipments
shipment_idorder_idcarrier
9001801dhl
9002801ups
9003802dhl

One order can own many shipments. orders.order_id identifies an order and appears once. shipments.order_id points at that identity and is allowed to repeat, which it does: order 801 went out in two boxes. That permission to repeat is the entire mechanism behind the extra rows.

The query and what it returns

SELECT o.order_id, o.total, s.carrier
FROM orders o
JOIN shipments s ON s.order_id = o.order_id
ORDER BY o.order_id, s.shipment_id;
3 rows
order_idtotalcarrierwhich pair this is
801125dhlorder 801 with shipment 9001
801125upsorder 801 with shipment 9002
80280dhlorder 802 with shipment 9003

Which left row matched which right rows

Read the output as a list of pairs and the count stops being surprising. Three input orders and three output rows here is a coincidence of the data. The output row count is the number of matching pairs, and it has no obligation to equal the row count of either table.

The number that actually breaks

SELECT SUM(o.total) AS revenue
FROM orders o
JOIN shipments s ON s.order_id = o.order_id;
1 row
revenue
330

Orders 801 and 802 are worth 205 between them. The query reports 330, because the 125 was counted once per shipment. Duplicate rows on screen are easy to notice. A sum that is 125 too high looks like a normal number, and no error is raised.

Tsumugi

Three hundred and thirty. You billed the courier and called it revenue.

Fix 1: collapse the many side before you join

If you want a shipment fact next to each order, reduce shipments to one row per order first, then join that. The order rows cannot multiply against something that has one row per order.

SELECT o.order_id, o.total, s.shipment_count
FROM orders o
LEFT JOIN (
  SELECT order_id, COUNT(*) AS shipment_count
  FROM shipments
  GROUP BY order_id
) s ON s.order_id = o.order_id
ORDER BY o.order_id;
3 rows
order_idtotalshipment_count
8011252
802801
803300NULL

Three orders, three rows, one row each. SUM(o.total) over this join returns 505, which is every order in the table, because the LEFT JOIN kept order 803 and padded its right-hand column with NULL. Before you write an ON clause, write the phrase "one row per ..." beside each side. When the two phrases differ, the finer side is the one that multiplies.

Fix 2: EXISTS, when you wanted no columns from the other table

The question "which orders shipped" never needed a carrier or a shipment id. A semi-join asks whether a match exists and stops there, so a second shipment cannot add a row.

SELECT o.order_id, o.total
FROM orders o
WHERE EXISTS (SELECT 1 FROM shipments s WHERE s.order_id = o.order_id)
ORDER BY o.order_id;
2 rows
order_idtotal
801125
80280

Fix 3: DISTINCT, and the case where it lies

SELECT DISTINCT o.order_id, o.total
FROM orders o
JOIN shipments s ON s.order_id = o.order_id
ORDER BY o.order_id;

That returns the same two rows as the EXISTS version, 801 with 125 and 802 with 80, and summing total over it gives 205. It works because order_id is in the select list, so two different orders stay two different rows even when everything else about them matches.

Take the key out and it stops working silently. Give order 802 a total of 125 as well, so two genuinely different orders carry the same number, and SELECT DISTINCT o.total collapses them into one row: the sum reads 125 where the right answer is 250. DISTINCT is safe over a join when the row identity you are counting is in the select list, and unsafe the moment it is not.

Check yourself

Order 802 also goes out in two boxes now, so shipments gains one row: (9004, 802, 'ups'). Before running anything, answer both parts. How many rows does the first query on this page return, and what does SUM(o.total) report for that join?

Show the answer

Four rows and 410. Order 801 pairs with two shipments and contributes 125 twice. Order 802 now pairs with two shipments and contributes 80 twice. Order 803 still has no shipment and is still absent. The pairs are 801/9001, 801/9002, 802/9003 and 802/9004.

4 rows
order_idtotalcarrier
801125dhl
801125ups
80280dhl
80280ups

Dialect note

Fan-out is part of how a join is defined, so every engine here behaves the same way. Every statement and every result table on this page was executed before publishing: on DuckDB 1.5.3, the engine family the game uses to grade queries in your browser, and again on SQLite 3.53, which returned identical rows. Postgres and MySQL 8 follow the same rules for matching pairs, for EXISTS and for DISTINCT.

One syntax difference is worth keeping in your fingers: Postgres and MySQL both require a name on a derived table, so the ) s at the end of the subquery in fix 1 is mandatory there. DuckDB and SQLite accept the subquery without one. Writing the alias always is the portable habit.

These are Module 3 problems in the game: Joins & Set Operations. Module 3 stays in the free itch build after the Nov 12 Steam launch. It runs in your browser, and a real database grades what you write. Play the free course

The Steam edition launches on Nov 12 with the whole course, holographic reward cards and a title-screen parallax. Wishlist on Steam