Joins
By the end of this lesson
Combine tables with inner, left, right and full joins, and predict the row count.
Your data is spread across tables on purpose. A join puts it back together for one query.
Joins are where SQL stops being a list of commands and starts being a skill. The syntax takes ten minutes. Predicting how many rows you get back takes longer, and it is the part that decides whether your totals are correct.
A join works on a condition, almost always matching a foreign key to a primary key. For each pair of rows where the condition holds, the database produces one output row containing columns from both.
Read that again with the emphasis on "each pair". The row count of the result is a consequence of how many pairs match, not of how many rows either table has.
SELECT
o.order_id,
o.order_date,
c.company_name,
c.country
FROM orders AS o
INNER JOIN customers AS c ON c.customer_id = o.customer_id
WHERE o.order_date >= '2026-01-01'
ORDER BY o.order_date DESC;- INNER JOIN keeps only rows where the condition is satisfied on both sides. An order with no matching customer would be dropped, and a customer with no orders never appears.
- The ON clause states how the tables relate. Forgetting it, or getting the columns wrong, is the single most expensive mistake in this lesson.
- The word INNER is optional — plain JOIN means the same thing. Writing it out makes the intent explicit for the next reader.
- Row count here: exactly one row per matching order, because each order has exactly one customer. That is the safe case.
Row multiplication: the bug that catches everyone
Join a one-to-many relationship and the "one" side repeats. Order 1001 has three items, so joining orders to order_items produces three rows for order 1001 — and the order's own columns are copied into all three.
That is correct behaviour. There is no other sensible answer. The problem is what happens when you then add up one of those repeated columns.
order_items for order 1001
--------------------------------------
order_item_id product_id quantity
5001 318 2
5002 412 1
5003 507 4
SELECT o.order_id, o.shipping_fee, oi.quantity
FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.order_id = 1001;
order_id shipping_fee quantity
1001 5.00 2
1001 5.00 1
1001 5.00 4
SUM(shipping_fee) over this result = 15.00
The actual shipping fee for order 1001 = 5.00- shipping_fee appears three times because the order row was repeated once per item. Summing it triples the figure.
- Nothing errored. The query is valid, the join is correct, and the number is wrong by 300%. This is why joins deserve more than ten minutes.
-- WRONG: shipping_fee is counted once per order item
SELECT SUM(o.shipping_fee) AS total_shipping
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.order_date >= '2026-01-01';
-- Correct option 1: do not join. The fee lives on orders, so count it there.
SELECT SUM(o.shipping_fee) AS total_shipping
FROM orders AS o
WHERE o.order_date >= '2026-01-01';
-- Correct option 2: aggregate the many side first, then join one row to one row
SELECT
SUM(o.shipping_fee) AS total_shipping,
SUM(items.line_total) AS total_goods
FROM orders AS o
JOIN (
SELECT oi.order_id, SUM(oi.quantity * oi.unit_price) AS line_total
FROM order_items AS oi
GROUP BY oi.order_id
) AS items ON items.order_id = o.order_id
WHERE o.order_date >= '2026-01-01';- Option 1 is the answer whenever you did not actually need the other table. Check what the join is for before adding it.
- Option 2 is the pattern for when you need totals from both levels. Collapsing order_items to one row per order first restores a one-to-one join, so neither figure is multiplied.
- Reaching for SELECT DISTINCT to remove the duplicates does not fix this. It would collapse the three identical rows to one, but it changes the quantity total too, and on real data the rows usually are not identical.
The four join types
The difference between them is only which unmatched rows survive:
- INNER JOIN
- Keeps rows that match on both sides. Unmatched rows from either table are dropped. This is the default choice and the right one most of the time.
- LEFT JOIN
- Keeps every row from the left table, matched or not. Where there is no match, the right table's columns come back as NULL. Use it for "all customers, with their orders if any".
- RIGHT JOIN
- The mirror image: every row from the right table. Legal and rarely used, because swapping the table order and writing LEFT JOIN reads better.
- FULL OUTER JOIN
- Keeps unmatched rows from both sides, filling the other side with NULL. Useful for reconciling two datasets that should agree. Not supported by MySQL, which needs a UNION of a left and a right join.
- CROSS JOIN
- Every row paired with every row, with no condition. 500 customers by 200 products is 100,000 rows. Occasionally deliberate, for generating combinations; usually an accident.
-- All customers, with an order count including the ones who have never ordered
SELECT
c.company_name,
COUNT(o.order_id) AS order_count
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.customer_id
GROUP BY c.company_name
ORDER BY order_count ASC;
-- Only the customers who have never ordered
SELECT c.company_name, c.country
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;- COUNT(o.order_id) counts non-null values, so a customer with no orders correctly shows 0. COUNT(*) would show 1, because that customer still produced one output row with NULLs in it. This distinction matters and is easy to miss.
- The second query uses the NULL that the LEFT JOIN creates as the filter. "No matching row on the right" becomes "the right table's key is NULL", which is a standard idiom worth recognising.
- Grouping is covered fully in the next lesson. Read GROUP BY here as "one output row per company name".
One older style is worth recognising because you will meet it in existing code: listing tables comma-separated and putting the join condition in WHERE, as in FROM orders o, customers c WHERE c.customer_id = o.customer_id. It produces the same result as an INNER JOIN.
Explicit JOIN syntax is preferred for new work. It separates "how these tables relate" from "which rows I want", and forgetting the ON clause is a syntax error rather than a silent cross join.
Summary
- A join produces one row per matching pair, so the result's row count follows the relationship, not the table sizes
- Joining a one-to-many relationship repeats the one side — summing its columns then double-counts
- Aggregate the many side to one row per parent before joining when you need totals from both levels
- INNER keeps matches only; LEFT keeps all rows from the left and fills the right with NULL
- A condition on the right table belongs in ON, not WHERE, or a LEFT JOIN silently becomes an INNER JOIN
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Write a query listing every line on order 1043: the product name, quantity, unit price charged, and the line total. Include the customer's company name.
Show solution
Three joins, following the relationships: orders to order_items (one to many), order_items to products (many to one), orders to customers (many to one).
The row count is one per order item, which is what you want here because each output row represents a line on the order. Nothing is multiplied incorrectly, because the only thing being repeated is customer and order data you are displaying rather than summing.
quantity * unit_price uses order_items.unit_price, not products.unit_price. The price on the order line is what was charged at the time; the product price may have changed since.
SELECT
c.company_name,
p.product_name,
oi.quantity,
oi.unit_price,
oi.quantity * oi.unit_price AS line_total
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
JOIN order_items AS oi ON oi.order_id = o.order_id
JOIN products AS p ON p.product_id = oi.product_id
WHERE o.order_id = 1043
ORDER BY p.product_name;Challenge
Challenge
A report shows total goods value and total shipping fees per customer for 2026. The author wrote one query joining customers, orders and order_items, with SUM on both figures. Shipping totals came out roughly four times too high.
Explain the cause, then write a version that is correct.
Show solution
Each order was repeated once per order item, so shipping_fee was summed once per line rather than once per order. Roughly four items per order gives roughly four times the shipping.
The goods total was correct, because quantity and unit_price live on order_items and each of those rows appeared exactly once.
The fix aggregates order_items down to one row per order before joining. The inner query produces one goods total per order; the outer query then joins one row to one row, so both sums are counted once.
A defensible alternative is two separate queries, one per figure, combined in the application. That is often clearer, and on large data it can be faster. Preferring a single clever query over two obvious ones is a habit worth questioning.
SELECT
c.company_name,
SUM(order_totals.goods_value) AS total_goods,
SUM(o.shipping_fee) AS total_shipping
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
JOIN (
SELECT oi.order_id, SUM(oi.quantity * oi.unit_price) AS goods_value
FROM order_items AS oi
GROUP BY oi.order_id
) AS order_totals ON order_totals.order_id = o.order_id
WHERE o.order_date >= '2026-01-01'
AND o.order_date < '2027-01-01'
GROUP BY c.company_name
ORDER BY total_goods DESC;Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.