Querying
Joins, and What Each One Actually Returns
An inner join silently deletes rows. Outer joins keep them and pad with NULL. The WHERE versus ON decision is what decides which of the two you actually get.
A report totals orders by customer and the numbers are wrong in one direction: too
low. Every order is counted, every amount is right, and the three customers who spent
the most are missing. Nothing in the query looks wrong. The JOIN keyword is
doing what it is defined to do, keeping only rows where both sides matched, and the
missing customers are the ones whose rows did not.
An inner join is a filter that does not announce itself as one. To keep rows with no partner you have to say so, and the word you add changes which side keeps its rows and what appears in the columns that had no match. The rest of this lesson is the four ways that can go, and the one clause placement that silently converts an outer join back into an inner one.
The dataset continues from the previous lesson: five customers, seven orders, seven order lines. Order 1004 has no line items, and orders 1004, 1006 and 1007 do not appear in the line table at all, which is deliberate, because an outer join with nothing to pad teaches nothing.
An inner join keeps only the rows that matched
A join takes two row sets and produces one by pairing rows that satisfy the condition. An inner join discards every row that found no partner, so the row count of the result is not the row count of either input.
CREATE TABLE line_item (
order_id INT NOT NULL,
line_no INT NOT NULL,
product_id INT NOT NULL,
qty INT NOT NULL,
unit_cents INT NOT NULL,
PRIMARY KEY (order_id, line_no),
CONSTRAINT fk_line_item_order FOREIGN KEY (order_id)
REFERENCES orders (order_id)
) ENGINE=InnoDB;
INSERT INTO line_item (order_id, line_no, product_id, qty, unit_cents) VALUES
(1001, 1, 7, 2, 1200), (1001, 2, 9, 1, 4500),
(1002, 1, 7, 1, 1200), (1003, 1, 9, 2, 4500),
(1005, 1, 7, 5, 1200), (1005, 2, 9, 3, 4500),
(1006, 1, 7, 1, 1200);
Seven orders and seven lines, and the inner join returns seven rows:
SELECT o.order_id, o.status, i.line_no
FROM orders o
JOIN line_item i ON i.order_id = o.order_id
ORDER BY o.order_id, i.line_no;
+----------+----------+---------+
| order_id | status | line_no |
+----------+----------+---------+
| 1001 | paid | 1 |
| 1001 | paid | 2 |
| 1002 | paid | 1 |
| 1003 | pending | 1 |
| 1005 | paid | 1 |
| 1005 | paid | 2 |
| 1006 | pending | 1 |
+----------+----------+---------+
Order 1004 is missing, and order 1007 with it. Both exist in orders,
neither has a line, and the inner join has no way to represent an order with no lines.
There is no partial row and no warning. For "which orders have lines" that result is
correct. For "what does each order contain" it is a lie of omission, because an order
that contains nothing is an answer.
An outer join keeps one side and pads the other with NULL
LEFT JOIN keeps every row from the left table, whether or not it matched.
Where no partner exists, the columns from the right table are filled with NULL. That
NULL is not a value someone stored; it is the absence of a row, materialised so the
result can stay rectangular.
SELECT o.order_id, o.status, i.line_no, i.qty
FROM orders o
LEFT JOIN line_item i ON i.order_id = o.order_id
ORDER BY o.order_id, i.line_no;
+----------+----------+---------+------+
| order_id | status | line_no | qty |
+----------+----------+---------+------+
| 1001 | paid | 1 | 2 |
| 1001 | paid | 2 | 1 |
| 1002 | paid | 1 | 1 |
| 1003 | pending | 1 | 2 |
| 1004 | refunded | NULL | NULL |
| 1005 | paid | 1 | 5 |
| 1005 | paid | 2 | 3 |
| 1006 | pending | 1 | 1 |
| 1007 | paid | NULL | NULL |
+----------+----------+---------+------+
Nine rows: the seven matched pairs stay, and two rows appear for the orders with no
lines. The row count grew because the left side is the one being preserved.
RIGHT JOIN preserves line_item and lands the NULLs on the order
columns. Since every line here has an order, a right join returns the same seven rows as
the inner join: RIGHT JOIN is usually a left join written the other way
round.
A FULL OUTER JOIN preserves both sides. MySQL has no such clause, so the
emulation is a left join unioned with the unmatched right rows; SQLite 3.51 supports
FULL OUTER JOIN directly, which makes the emulation easy to check:
DROP TABLE IF EXISTS legacy_order;
CREATE TABLE legacy_order (
order_id INT NOT NULL,
customer_id INT NOT NULL,
note VARCHAR(40) NOT NULL
);
INSERT INTO legacy_order VALUES
(1001, 1, 'migrated'),
(1099, 99, 'unknown customer');
SELECT c.customer_id AS cust, l.order_id AS legacy_order, l.note
FROM customer c
LEFT JOIN legacy_order l ON l.customer_id = c.customer_id
UNION
SELECT c.customer_id, l.order_id, l.note
FROM customer c
RIGHT JOIN legacy_order l ON l.customer_id = c.customer_id
WHERE c.customer_id IS NULL
ORDER BY cust IS NULL, cust, legacy_order;
+------+--------------+------------------+
| cust | legacy_order | note |
+------+--------------+------------------+
| 1 | 1001 | migrated |
| 2 | NULL | NULL |
| 3 | NULL | NULL |
| 4 | NULL | NULL |
| 5 | NULL | NULL |
| NULL | 1099 | unknown customer |
+------+--------------+------------------+
Two kinds of padding in one result. Customers 2 to 5 have no legacy order, so the
right-hand columns are NULL; order 1099 has a customer id that no customer has, so the
left-hand columns are NULL. UNION rather than UNION ALL is safe
here because the second branch's WHERE c.customer_id IS NULL contributes
only unmatched rows.
Putting the filter in WHERE turns an outer join back into an inner one
This is the mistake the lesson exists for. Filtering the null-padded side in
WHERE removes the rows the outer join went to the trouble of keeping,
because a NULL cannot satisfy a comparison. The query still runs and still returns rows,
and is now an inner join wearing an outer join's keyword.
SELECT c.customer_id, c.email, o.order_id, o.status
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.customer_id AND o.status = 'paid'
ORDER BY c.customer_id, o.order_id;
+-------------+-----------------+----------+--------+
| customer_id | email | order_id | status |
+-------------+-----------------+----------+--------+
| 1 | ana@example.com | 1001 | paid |
| 1 | ana@example.com | 1002 | paid |
| 1 | ana@example.com | 1007 | paid |
| 2 | bo@example.com | 1005 | paid |
| 3 | cai@example.com | NULL | NULL |
| 4 | dee@example.com | NULL | NULL |
| 5 | eve@example.com | NULL | NULL |
+-------------+-----------------+----------+--------+
Seven rows. Customers 3, 4 and 5 keep their place with NULL order columns. That is what a left join is for: the status condition is part of the match, so a customer whose only orders are pending still appears, with nothing on the right.
Now move the same condition into WHERE:
SELECT c.customer_id, c.email, o.order_id, o.status
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = 'paid'
ORDER BY c.customer_id, o.order_id;
+-------------+-----------------+----------+--------+
| customer_id | email | order_id | status |
+-------------+-----------------+----------+--------+
| 1 | ana@example.com | 1001 | paid |
| 1 | ana@example.com | 1002 | paid |
| 1 | ana@example.com | 1007 | paid |
| 2 | bo@example.com | 1005 | paid |
+-------------+-----------------+----------+--------+
Four rows, and three customers have vanished. The left join produced the padded rows,
then WHERE evaluated NULL = 'paid' on each of them, got unknown, and
discarded them. The two statements differ by four rows and nothing else.
The rule that follows is mechanical. A condition that decides whether a row on the
optional side is a match belongs in ON. A condition that decides whether a
preserved row survives belongs in WHERE. "Every customer who has at least
one paid order" is the WHERE version, and it would be clearer as an inner join.
The NULL check is not the same filter
WHERE o.order_id IS NULL after a left join is a legitimate and useful
filter: it keeps exactly the rows where no match was found, which turns the join into
an anti join. It is the opposite of the trap above, and it is how you find customers
with no orders at all.
A join without a condition multiplies instead of matching
Write JOIN and forget the ON, and MySQL returns the cross
product. Five customers times seven orders is 35 rows, each customer paired with every
order, and nothing in the output says the pairing is meaningless.
SELECT COUNT(*) AS accidental_pairs FROM customer c JOIN orders o;
SELECT COUNT(*) AS deliberate_pairs FROM customer CROSS JOIN orders;
+------------------+
| accidental_pairs |
+------------------+
| 35 |
+------------------+
+------------------+
| deliberate_pairs |
+------------------+
| 35 |
+------------------+
Both return 35, which is the point: the two statements are the same statement. A cross
join is occasionally what you want, and when it is, write CROSS JOIN so the
next reader knows the multiplication was intended. The failure mode is what happens
downstream: a cross join in a chain of three tables multiplies again, and a
SUM over the result is wrong by a factor nobody notices until the invoice is
sent.
Two habits catch it early. Give every join an explicit ON or
USING, and compare the row count of the join against the row counts of its
inputs before you aggregate anything.
A self join relates rows of one table to each other
Nothing requires the two sides of a join to be different tables. A self join gives one table two aliases and relates its rows to each other. The question it answers is which rows pair with which: orders from the same customer, employees under the same manager.
SELECT a.order_id AS first_order, b.order_id AS second_order, a.customer_id
FROM orders a
JOIN orders b
ON b.customer_id = a.customer_id
AND b.order_id > a.order_id
ORDER BY a.order_id, b.order_id;
+-------------+--------------+-------------+
| first_order | second_order | customer_id |
+-------------+--------------+-------------+
| 1001 | 1002 | 1 |
| 1001 | 1007 | 1 |
| 1002 | 1007 | 1 |
| 1003 | 1005 | 2 |
| 1004 | 1006 | 4 |
+-------------+--------------+-------------+
The b.order_id > a.order_id is doing two jobs. It prevents a row from
pairing with itself, and it prevents the mirror pair from appearing as well, so a pair of
orders shows up once rather than twice. Drop it and the count goes to 17, because every
order now pairs with all seven orders from its own customer, including itself. Drop the
whole ON clause and the join becomes a cross product, which is the next
section. That asymmetry between > and <> is the
difference between a combination and a permutation, and it is the reason self joins
written without thinking about the inequality produce double-counted reports.
The same technique with a NULL check gives an anti join: left join the table to
itself, then keep the rows where the right-hand copy is NULL. On this dataset that
returns the orders with no lines, the two rows the inner join dropped at the start. It
is worth knowing because it works on every engine, and it is also the shape that
NOT EXISTS usually replaces with something shorter.
USING and ON name the same join differently
When both sides name the join column identically, USING (column) is
shorter than ON and it changes the result set in one visible way: the join
column appears once instead of twice.
SELECT * FROM orders JOIN line_item USING (order_id) WHERE orders.order_id = 1001;
SELECT * FROM orders JOIN line_item ON line_item.order_id = orders.order_id
WHERE orders.order_id = 1001;
USING: order_id, customer_id, status, placed_at, total_cents,
line_no, product_id, qty, unit_cents
ON: order_id, customer_id, status, placed_at, total_cents,
order_id, line_no, product_id, qty, unit_cents
Nine columns against ten. With USING, the merged column is unqualified and
belongs to both tables, which also means USING only works when the column
names match. Where the parent key is orders.order_id and the child column is
line_item.order_id it works; where the parent key is orders.id
and the child column is order_id, MySQL rejects the statement with
Unknown column 'order_id' in 'from clause'.
My preference is ON almost everywhere: it is explicit about the columns
being compared, it survives a rename on one side, and it is the only option for the
non-equality joins where the interesting queries live. USING earns its
place when the join column is genuinely the same concept on both sides.
Use the customer, orders, line_item and
legacy_order tables. Write each statement, then answer the question
about its row count before you run it.
- A list of every order with its customer email, including orders that have no line item and customers who have never ordered. Predict the row count first, then say which join produced it and why the other two joins do not.
- Every customer with their paid orders, keeping customers who have none. Then
rewrite the same filter into
WHEREand account for the difference in rows. - Every order with no line item, without using
NOT IN. There are two shapes that work; name the one you chose and what it would return if aline_itemrow arrived with a NULLorder_id. - A list of order pairs placed by the same customer, where the second order came
after the first, oldest first. Explain what the
>is preventing.
Show the solution and the reasoning
The first item needs every customer and every order, with the lines attached where they exist. One join cannot do it, because a left join keeps customers and a right join keeps orders:
SELECT c.customer_id, c.email, o.order_id, i.line_no
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.customer_id
LEFT JOIN line_item i ON i.order_id = o.order_id
UNION
SELECT c.customer_id, c.email, o.order_id, i.line_no
FROM customer c
RIGHT JOIN orders o ON o.customer_id = c.customer_id
LEFT JOIN line_item i ON i.order_id = o.order_id
WHERE c.customer_id IS NULL
ORDER BY customer_id IS NULL, customer_id, order_id, line_no;
+-------------+-----------------+----------+---------+
| customer_id | email | order_id | line_no |
+-------------+-----------------+----------+---------+
| 1 | ana@example.com | 1001 | 1 |
| 1 | ana@example.com | 1001 | 2 |
| 1 | ana@example.com | 1002 | 1 |
| 1 | ana@example.com | 1007 | NULL |
| 2 | bo@example.com | 1003 | 1 |
| 2 | bo@example.com | 1005 | 1 |
| 2 | bo@example.com | 1005 | 2 |
| 3 | cai@example.com | NULL | NULL |
| 4 | dee@example.com | 1004 | NULL |
| 4 | dee@example.com | 1006 | 1 |
| 5 | eve@example.com | NULL | NULL |
+-------------+-----------------+----------+---------+
Eleven rows, and the count is not the count of anything else. It came from the many
side of two one-to-many joins: the two customers with no orders contributed a padded
row each, and so did the two orders with no lines. The second branch exists for orders
whose customer is missing, and on this dataset it adds nothing because the foreign key
makes that impossible. In SQLite the same result is one FULL OUTER JOIN
between customer and orders, left joined to line_item.
The second item is the WHERE versus ON trap, and the answer differs by the customers who have no paid orders:
SELECT c.customer_id, c.email, o.order_id
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.customer_id AND o.status = 'paid'
ORDER BY c.customer_id, o.order_id;
Seven rows, with NULL order columns for customers 3, 4 and 5. When
o.status = 'paid' moves to WHERE, those three padded rows
fail the comparison because NULL is not 'paid', and the result drops to
four rows. The condition means something different in each place: in ON
it tests whether an order is a match, and failing it does not disqualify the customer;
in WHERE it tests whether the joined row survives.
The third item asks for orders with no lines, and there are two shapes. The anti join with a NULL check:
SELECT o.order_id
FROM orders o
LEFT JOIN line_item i ON i.order_id = o.order_id
WHERE i.order_id IS NULL
ORDER BY o.order_id;
+----------+
| order_id |
+----------+
| 1004 |
| 1007 |
+----------+
And the NOT EXISTS version, which gives the same two rows without
materialising the padded row:
SELECT o.order_id
FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM line_item i WHERE i.order_id = o.order_id
)
ORDER BY o.order_id;
Both are correct on this data, and I would ship the second. The NULL-check version
assumes that line_item.order_id can never be NULL: a line row with a NULL
order_id would pair with no order, and
WHERE i.order_id IS NULL would report it as an order with no lines.
NOT EXISTS asks whether a row exists, which is a question with no NULL in
it.
The fourth item needs the self join with a strict inequality:
SELECT a.order_id AS first_order, b.order_id AS second_order, a.customer_id
FROM orders a
JOIN orders b
ON b.customer_id = a.customer_id
AND b.order_id > a.order_id
ORDER BY a.order_id, b.order_id;
Five rows, as in the section above. The > removes the self-pairing
and the mirrored pair, so a combination appears once instead of twice. Switch to
<> and the count goes from five to ten, because both directions
survive: right for a directed relationship, a bug for a combination.
Progress
Mark this lesson done and it stays marked in this browser.