A wrong JOIN doesn’t throw an error. It runs, returns a result set, and the rows are wrong: customers missing, totals inflated, orphaned records hidden. Most of the time the query asked for exactly what it got, and the author picked the wrong join type or filtered in the wrong place.
A join combines rows from two tables on a condition you write. Which rows survive depends on the join type, and the six types below give six different answers from the same two tables.
customers holds five people. Three of them were referred by another customer, recorded in referred_by:
| |
orders holds six orders. Five point at a real customer. The sixth, 106, points at customer 99, who was deleted after placing it, so the order no longer matches anyone:
| |
The quickest way to follow along is a Postgres container, set up the same way as in What Is Docker Compose and How to Use It. The joins are standard SQL. The two places where MySQL differs are called out below.
INNER JOIN: only the rows that match on both sides
| |
| name | order_id | amount |
|---|---|---|
| Alice | 101 | 50.00 |
| Alice | 102 | 20.00 |
| Bob | 103 | 75.00 |
| Dave | 104 | 30.00 |
| Dave | 105 | 15.00 |
Five rows. Carol and Erin have no orders, so they’re gone. Order 106 has no customer, so it’s gone too. JOIN and INNER JOIN mean the same thing. INNER is optional and most people leave it out.
On five rows nothing here is slow. On a real orders table, orders.customer_id needs an index, or every join against it scans the whole table. How Database Indexes Work and When to Add One covers when that index pays off.
LEFT JOIN: every row on the left, matched or not
| |
| name | order_id | amount |
|---|---|---|
| Alice | 101 | 50.00 |
| Alice | 102 | 20.00 |
| Bob | 103 | 75.00 |
| Carol | NULL | NULL |
| Dave | 104 | 30.00 |
| Dave | 105 | 15.00 |
| Erin | NULL | NULL |
Every customer is there. Carol and Erin get NULL in the order columns. This is the join you’ll write most often: “all of X, with whatever Y they have.”
RIGHT JOIN: every row on the right, matched or not
| |
| name | order_id | amount |
|---|---|---|
| Alice | 101 | 50.00 |
| Alice | 102 | 20.00 |
| Bob | 103 | 75.00 |
| Dave | 104 | 30.00 |
| Dave | 105 | 15.00 |
| NULL | 106 | 10.00 |
Order 106 is back, with a NULL name. This is the orphaned row the inner join hid, and a right join (or the equivalent left join) is how you find it on purpose.
You’ll rarely see RIGHT JOIN in real code. a RIGHT JOIN b returns the same rows as b LEFT JOIN a, so most teams use LEFT JOIN everywhere and change the table order instead. Rewriting a RIGHT JOIN that way is about consistency. The original is not wrong.
FULL OUTER JOIN: everything from both sides
| |
| name | order_id | amount |
|---|---|---|
| Alice | 101 | 50.00 |
| Alice | 102 | 20.00 |
| Bob | 103 | 75.00 |
| Carol | NULL | NULL |
| Dave | 104 | 30.00 |
| Dave | 105 | 15.00 |
| Erin | NULL | NULL |
| NULL | 106 | 10.00 |
Eight rows: every match, plus the unmatched customers, plus the unmatched order. That makes it the join for reconciliation, where you want everything on either side that doesn’t line up. Outside that, you won’t need it much.
MySQL has no FULL OUTER JOIN, and it doesn’t accept NULLS LAST either. To get the same rows there, take the LEFT JOIN, then UNION ALL a RIGHT JOIN filtered with WHERE c.customer_id IS NULL so the matched rows aren’t repeated. A plain UNION of the two joins also works, but it pays for a deduplication pass and also merges rows that really are identical.
CROSS JOIN: every row paired with every row
| |
Five customers times six orders: thirty rows, with no join condition at all. Most cross joins in production are accidents. Someone wrote FROM customers, orders and forgot the WHERE that was supposed to connect them. You’d want one on purpose to generate combinations, like every size with every color, or every day in a date range with every store, before filling in the real figures.
How does a self-join work?
customers.referred_by points at another row in the same table. To show who referred whom by name, join the table to itself under a second alias:
| |
| customer | referred_by |
|---|---|
| Alice | NULL |
| Bob | NULL |
| Carol | Alice |
| Dave | Alice |
| Erin | Bob |
The join itself works the same way as before. customers c and customers r are two aliases, and the planner treats them as two separate tables that happen to hold the same rows. It’s a LEFT JOIN so that Alice and Bob, who weren’t referred, stay in the result. Any table where a row points at another row in the same table works this way: employees and managers, categories and parent categories, comments and replies.
Why does a WHERE clause turn a LEFT JOIN into an INNER JOIN?
You want every customer, with their orders over $25 where they have any:
| |
| name | order_id | amount |
|---|---|---|
| Alice | 101 | 50.00 |
| Bob | 103 | 75.00 |
| Dave | 104 | 30.00 |
Carol and Erin are gone. WHERE runs after the join, and for them o.amount is NULL. NULL > 25 is neither true nor false. It’s unknown, and WHERE keeps only rows that are true. The LEFT JOIN kept them and the filter threw them out.
| |
Use the first one. Putting the condition in ON decides which orders are allowed to match, and every customer still gets at least one row. The second version only lets through rows where the join found nothing. A customer whose only order was $20 gets a row with amount = 20.00, which fails both conditions, and that customer disappears again. On this data both queries happen to keep all five customers, which is how the second one gets through testing.
Why does SUM return the wrong total after joining two tables?
Add a third table, payments, where one order can be paid in several installments:
| |
Order 101 is $50, paid in two $25 installments. Now join all three tables and total what each customer ordered:
| |
| name | total_ordered |
|---|---|
| Alice | 100.00 |
| Bob | 75.00 |
Alice’s real total is $70 (50.00 + 20.00). Two things went wrong. Order 101 matches two payments, so the join returns that order row twice and SUM(o.amount) adds its $50 twice. Order 102 has no payments, so the inner join to payments drops it. For Alice that gives 50 + 50 + 0 = 100. Dave has no payments at all, so he isn’t in the result.
The rule: when you aggregate a column, don’t join tables the aggregate doesn’t need. Every one-to-many join can multiply rows or remove them. If you only want order totals, leave payments out:
| |
If you need payment totals in the same report, GROUP BY order_id on payments in a subquery or CTE first. That gives one row per order, and you can join it to the orders safely.
NOT IN vs NOT EXISTS: how do you find rows with no match?
“Customers with no orders” looks like a job for NOT IN:
| |
It works here only because orders.customer_id is NOT NULL. Drop that constraint and insert a single order with a NULL customer, and the query returns zero rows, with no error. NOT IN expands to a chain of <> comparisons joined by AND. x <> NULL is unknown, so one NULL makes the whole condition unknown for every customer.
Two patterns don’t have this problem:
| |
| name |
|---|
| Carol |
| Erin |
Both return Carol and Erin, and a NULL in orders.customer_id doesn’t affect either one. Neither builds a list to compare against. Both check, customer by customer, whether a matching order exists. Use NOT EXISTS by default. Use LEFT JOIN ... IS NULL when the query already joins the two tables to get other columns.
When a join returns more rows than you expect, look for a one-to-many table between the join and the GROUP BY. When it returns fewer, look for a WHERE condition on the table that’s allowed to be NULL. In both cases, fix the query around the join before you change the join type.