Joins — combining tables without double counting
INNER, LEFT, RIGHT, FULL and self joins, multi-table joins, and the fan-out problem that silently inflates totals.
This lesson builds on: Aggregations, GROUP BY and HAVING.
- Choose the right join type for a question and predict how many rows it returns
- Join three or more tables using keys
- Find unmatched rows with LEFT JOIN … IS NULL
- Recognise and prevent fan-out (double counting) when joining tables of different grain
Why joins exist
Relational databases split information across tables to avoid repeating it: the store's city is stored once in stores, not on every order. To answer "revenue by city" you must join orders to stores on store_id.
A join pairs rows from two tables whose key values match, according to a condition in ON.
INNER JOIN — only matching rows
SELECT o.order_id, o.order_date, s.store_name, s.city
FROM orders AS o
INNER JOIN stores AS s ON s.store_id = o.store_id
ORDER BY o.order_id
LIMIT 5;| order_id | order_date | store_name | city |
|---|---|---|---|
| 1 | 2023-01-01 | Orion Baku Airport | Baku |
| 2 | 2023-01-01 | Orion Baku Riverside | Baku |
| 3 | 2023-01-01 | Orion Ganja | Ganja |
| 4 | 2023-01-01 | Orion Istanbul Kadikoy | Istanbul |
| 5 | 2023-01-01 | Orion Baku Riverside | Baku |
o and s are table aliases. Prefixing every column with its table (o.order_id) makes queries readable and avoids "ambiguous column" errors when both tables have a column with the same name. JOIN alone means INNER JOIN.
An inner join drops rows that have no partner. Every order has a valid store here, so nothing is lost — but that is something to verify, not assume.
LEFT JOIN — keep everything on the left
LEFT JOIN keeps all rows of the left table; where there is no match, the right table's columns are NULL. It answers questions like "all stores, with their 2025 plan if they have one":
SELECT s.store_name, SUM(t.target_revenue) AS plan_2025
FROM stores AS s
LEFT JOIN sales_targets AS t
ON t.store_id = s.store_id
AND t.year_month LIKE '2025%'
GROUP BY s.store_name
ORDER BY plan_2025;| store_name | plan_2025 |
|---|---|
| Orion Baku Airport | NULL |
| Orion Sumqayit | 71900 |
| Orion Ganja | 88900 |
| Orion Tbilisi | 103200 |
| Orion Baku Riverside | 117800 |
| Orion Ankara | 124900 |
| Orion Baku Central | 182600 |
| Orion Istanbul Kadikoy | 195800 |
| Orion Online | 246600 |
Baku Airport closed in June 2024, so it has no 2025 plan — it still appears, with NULL. An inner join would have hidden it.
Had we written WHERE t.year_month LIKE '2025%' instead of putting the condition in ON, Baku Airport's row (where t.year_month is NULL) would be removed, because NULL LIKE … is not TRUE. Conditions on the right-hand table of a LEFT JOIN belong in ON.
Finding what is missing: the anti-join
LEFT JOIN + IS NULL finds rows without a match — customers who never ordered, products never sold:
SELECT COUNT(*) AS customers_without_orders
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;| customers_without_orders |
|---|
| 494 |
RIGHT JOIN and FULL OUTER JOIN
RIGHT JOINis a LEFT JOIN with the tables swapped. Most analysts rewrite it as a LEFT JOIN for readability.FULL OUTER JOINkeeps unmatched rows from both sides — ideal for reconciliation ("which store-months have actuals but no plan, or a plan but no actuals?").
WITH actual AS (
SELECT store_id, substr(order_date, 1, 7) AS year_month, COUNT(*) AS orders
FROM orders WHERE order_date >= '2025-01-01' GROUP BY 1, 2
)
SELECT COALESCE(a.store_id, t.store_id) AS store_id,
COALESCE(a.year_month, t.year_month) AS year_month,
a.orders, t.target_revenue
FROM actual AS a
FULL OUTER JOIN (SELECT * FROM sales_targets WHERE year_month >= '2025-01') AS t
ON t.store_id = a.store_id AND t.year_month = a.year_month
WHERE a.store_id IS NULL OR t.store_id IS NULL;| store_id | year_month | orders | target_revenue |
|---|
No rows: every 2025 store-month has both a plan and actual orders. An empty result is a useful result in reconciliation. (The WITH clause is a CTE — explained in the next lesson.)
Self joins
A self join joins a table to itself, for comparing rows of the same table. Example: pairs of stores in the same city.
SELECT a.store_name AS store_a, b.store_name AS store_b, a.city
FROM stores AS a
JOIN stores AS b
ON a.city = b.city
AND a.store_id < b.store_id;| store_a | store_b | city |
|---|---|---|
| Orion Baku Central | Orion Baku Riverside | Baku |
| Orion Baku Central | Orion Baku Airport | Baku |
| Orion Baku Riverside | Orion Baku Airport | Baku |
a.store_id < b.store_id avoids pairing a store with itself and listing each pair twice. Self joins are also used for hierarchies (employee → manager).
Joining several tables
Revenue by category and channel for completed 2025 orders needs three tables:
SELECT p.category,
o.channel,
ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct)), 0) AS net_revenue
FROM orders AS o
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.status = 'completed'
AND o.order_date >= '2025-01-01' AND o.order_date < '2026-01-01'
GROUP BY p.category, o.channel
ORDER BY p.category, o.channel;| category | channel | net_revenue |
|---|---|---|
| Apparel | online | 22346 |
| Apparel | store | 68912 |
| Beauty | online | 29905 |
| Beauty | store | 113057 |
| Electronics | online | 115383 |
| Electronics | store | 448428 |
| Grocery | online | 32655 |
| Grocery | store | 108381 |
| Home & Kitchen | online | 29261 |
| Home & Kitchen | store | 120103 |
Each join adds columns; WHERE filters on the combined rows; GROUP BY summarises.
The fan-out problem
When you join a table to another with a finer grain, rows of the coarser table are repeated. orders has one row per order; order_items has one row per line. Summing an order-level column after the join counts it once per line:
SELECT
(SELECT COUNT(*) FROM orders WHERE campaign_id IS NOT NULL) AS campaign_orders_correct,
(SELECT COUNT(*) FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.campaign_id IS NOT NULL) AS campaign_orders_after_join;| campaign_orders_correct | campaign_orders_after_join |
|---|---|
| 10225 | 20728 |
The second number is inflated by the average number of lines per order. Totals like this look plausible, which is why fan-out survives into dashboards.
- Know the grain of every table before joining.
- Aggregate before joining when combining tables of different grain (e.g. sum order lines per order in a CTE, then join to orders).
- Reconcile: compare your total with an independent count (
COUNT(*) FROM orders). If they differ, find out why before publishing.
Practice
Return each store's store_name and its number of completed orders in 2025. Two columns; include only stores that had such orders.
How many products were never ordered in any order line? One number.
Return each product category and its net revenue from completed orders in 2025, rounded to 0 decimals. Net revenue per line = quantity * unit_price * (1 - discount_pct).
You join orders (one row per order) to order_items (one row per line) and compute SUM(o.discount_pct). The result is about 2× larger than SUM(discount_pct) on orders alone. Why?
Summary
- INNER keeps matches only; LEFT keeps all left rows; FULL keeps everything; RIGHT is a mirrored LEFT.
- Put conditions on the right table of a LEFT JOIN in
ON, notWHERE. - LEFT JOIN …
IS NULLfinds missing relationships; self joins compare rows within a table. - Joining to a finer-grained table repeats rows — aggregate first and reconcile totals.