๐ Joins and Relationships
Relational databases get their power from not cramming everything into one giant table. Data lives in separate, focused tables that reference each other by key โ and joins are how you stitch them back together on demand. Master joins and you can answer almost any question your data holds.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Describe the three relationship types โ one-to-one, one-to-many, many-to-many โ and how foreign keys model them
- Write
INNER JOINandLEFT JOINqueries and predict exactly which rows each returns - Explain
RIGHT JOIN,FULL OUTER JOIN,CROSS JOIN, andSELF JOIN, and when each is the right tool - Join three or more tables to build a complete report
- Diagnose and fix the two classic join bugs: duplicate rows and missing rows
Estimated Time: 45โ55 minutes โข Difficulty: Intermediate
Hands-on: Build linked customers and orders tables and answer relationship questions with the right join.
In This Lesson
Why Split Data Across Tables?
Imagine storing a customer's name, email, and phone on every single order they place. A customer with 200 orders would have their email duplicated 200 times โ and changing that email would mean updating 200 rows, any of which could be missed. This duplication is exactly what relational design avoids through normalization: each fact is stored once, in the table it belongs to, and other tables refer to it by a key.
๐ก Analogy โ a department store: A store doesn't pile electronics, clothing, and groceries into one heap. Each department is organized for its own goods (its own table), and a directory helps you move between them (foreign keys). Joins are you walking the directory to gather items from several departments into one basket.
๐ Key Terms
Primary key (PK): a column that uniquely identifies each row in its table (e.g. customer_id).
Foreign key (FK): a column that points at another table's primary key (e.g. orders.customer_id references customers.customer_id).
Join: an operation that combines rows from two tables by matching a related column, producing one wider result row.
The Three Relationship Types
Nearly every schema is built from three patterns:
- One-to-One (1:1): each row on one side matches exactly one on the other. Example: a
userand theirprofile. Often used to split rarely-accessed or sensitive columns into their own table. - One-to-Many (1:N): one row relates to many rows on the other side, but each of those points back to just one. Example: a
customerhas manyorders; each order has one customer. This is the most common relationship. The FK lives on the "many" side. - Many-to-Many (N:M): rows on both sides can relate to many on the other. Example:
studentsandcourses. SQL can't store this directly, so you add a junction table (hereenrollment) holding a FK to each side.
INNER JOIN
An INNER JOIN returns only rows that have a match in both tables. It's the join you'll reach for most.
SELECT columns
FROM table1
INNER JOIN table2 ON table1.key = table2.key;
We'll use this tiny e-commerce dataset throughout. Note that Alice (id 4) has no orders:
| customers | |
|---|---|
| customer_id | name |
| 1 | John Smith |
| 2 | Jane Doe |
| 3 | Bob Johnson |
| 4 | Alice Brown |
| orders | ||
|---|---|---|
| order_id | customer_id | amount |
| 101 | 1 | 150.50 |
| 102 | 1 | 89.99 |
| 103 | 2 | 49.95 |
| 104 | 3 | 75.00 |
SELECT c.customer_id, c.name, o.order_id, o.amount
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id
ORDER BY c.customer_id;
Result โ 4 rows (Alice is absent):
| customer_id | name | order_id | amount |
|---|---|---|---|
| 1 | John Smith | 101 | 150.50 |
| 1 | John Smith | 102 | 89.99 |
| 2 | Jane Doe | 103 | 49.95 |
| 3 | Bob Johnson | 104 | 75.00 |
Alice has no matching order, so INNER JOIN drops her entirely. That's the defining behavior: no match on either side means the row disappears.
LEFT JOIN (and RIGHT)
A LEFT JOIN (a.k.a. LEFT OUTER JOIN) keeps every row from the left table, matching rows from the right where possible and filling in NULL where there's no match.
SELECT c.customer_id, c.name, o.order_id, o.amount
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
ORDER BY c.customer_id;
Result โ 5 rows (Alice appears with NULLs):
| customer_id | name | order_id | amount |
|---|---|---|---|
| 1 | John Smith | 101 | 150.50 |
| 1 | John Smith | 102 | 89.99 |
| 2 | Jane Doe | 103 | 49.95 |
| 3 | Bob Johnson | 104 | 75.00 |
| 4 | Alice Brown | NULL | NULL |
INNER JOIN keeps only the overlap; LEFT JOIN keeps all of the left table plus whatever matches on the right.The classic LEFT JOIN trick: find the orphans
Combine a LEFT JOIN with WHERE right_key IS NULL to find rows on the left that have no match โ here, customers who have never ordered:
SELECT c.customer_id, c.name
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL; -- keeps only the unmatched (Alice)
RIGHT JOIN
A RIGHT JOIN is the mirror image: it keeps every row from the right table. In practice it's rarely used โ most developers just swap the table order and write a LEFT JOIN, which reads more naturally. These two are equivalent:
-- Every order, with customer info if available
SELECT o.order_id, c.name
FROM customers c RIGHT JOIN orders o ON c.customer_id = o.customer_id;
-- Same result, written as a LEFT JOIN
SELECT o.order_id, c.name
FROM orders o LEFT JOIN customers c ON o.customer_id = c.customer_id;
FULL OUTER, CROSS & SELF
FULL OUTER JOIN
Returns all rows from both tables, matching where possible and using NULL on whichever side lacks a match. Useful for reconciliation โ spotting rows that exist on one side but not the other.
SELECT c.customer_id, c.name, o.order_id
FROM customers c
FULL OUTER JOIN orders o ON c.customer_id = o.customer_id;
๐ก MySQL has no FULL OUTER JOIN
PostgreSQL and SQL Server support it directly; MySQL and SQLite (older versions) do not. Simulate it by UNION-ing a LEFT JOIN and a RIGHT JOIN:
SELECT c.customer_id, c.name, o.order_id
FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id
UNION
SELECT c.customer_id, c.name, o.order_id
FROM customers c RIGHT JOIN orders o ON c.customer_id = o.customer_id;
CROSS JOIN
Produces the Cartesian product โ every row of A paired with every row of B, with no matching condition. If A has M rows and B has N, you get M ร N rows.
-- Generate every product/size combination (2 products ร 3 sizes = 6 rows)
SELECT p.product_name, s.size_name
FROM products p
CROSS JOIN sizes s
ORDER BY p.product_name, s.size_name;
โ ๏ธ CROSS JOIN explodes fast
Two 10,000-row tables produce 100 million rows. Use it deliberately (generating variant combinations, date grids) and never by accident โ an unintended cross join, caused by forgetting the ON condition, is a common performance disaster.
SELF JOIN
A SELF JOIN isn't a new join type โ it's joining a table to itself using two aliases. It's how you traverse hierarchies stored in one table, like an employee/manager chain:
SELECT e.name AS employee, e.title,
m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.employee_id
ORDER BY e.employee_id;
Here e is "the employee" and m is "the employee acting as manager." A LEFT JOIN ensures the CEO โ who has no manager โ still appears, with NULL for the manager column.
Joining Multiple Tables
Real reports chain several joins. To list what each customer bought, you walk customers โ orders โ order_details โ products:
SELECT c.name AS customer,
o.order_id,
p.product_name,
od.quantity,
od.unit_price,
(od.quantity * od.unit_price) AS line_total
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id
INNER JOIN order_details od ON o.order_id = od.order_id
INNER JOIN products p ON od.product_id = p.product_id
ORDER BY o.order_id, p.product_name;
โ Tips for multi-table joins
- Alias every table (
c,o,p) so column references are short and unambiguous. - Add tables one at a time when building a big query โ verify each step's row count before adding the next.
- Mind the join type at each step: one
INNER JOINin a chain can silently drop rows an earlierLEFT JOINpreserved.
Duplicate & Missing Rows
Problem 1 โ unexpected duplicates
When one row on the "one" side matches many on the "many" side, its data repeats once per match. A customer with 3 orders shows up 3 times. That's correct behavior, but if you only wanted a per-customer summary it looks like duplication. The fix is to aggregate with GROUP BY:
SELECT c.customer_id, c.name,
COUNT(o.order_id) AS order_count,
COALESCE(SUM(o.amount), 0) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name
ORDER BY total_spent DESC;
Using LEFT JOIN plus COALESCE means Alice still appears, with a count and total of 0 rather than being dropped.
Problem 2 โ unexpectedly missing rows
If a query silently loses rows you expected, the usual culprit is an INNER JOIN where you needed to preserve unmatched rows. Switch the relevant join to LEFT JOIN.
โ ๏ธ A filter in WHERE can turn a LEFT JOIN back into an INNER JOIN
Referencing a right-table column in WHERE (other than IS NULL) discards the NULL-filled rows, defeating the outer join. If you need to filter the right table but keep unmatched left rows, put the condition in the ON clause instead:
-- Keeps all customers; only "large" orders are joined in
SELECT c.name, o.order_id, o.amount
FROM customers c
LEFT JOIN orders o
ON c.customer_id = o.customer_id
AND o.amount > 100;
Hands-on Exercise
๐๏ธ Answer relationship questions with the right join
Objective: Build two linked tables, then choose the correct join for each question.
Step 1 โ Set up linked tables
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
name VARCHAR(80) NOT NULL
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(customer_id),
amount DECIMAL(8,2) NOT NULL
);
INSERT INTO customers VALUES (1,'John'),(2,'Jane'),(3,'Bob'),(4,'Alice');
INSERT INTO orders VALUES
(101,1,150.50),(102,1,89.99),(103,2,49.95),(104,3,75.00);
Step 2 โ Write a query for each
- List every customer together with each of their orders (customers with no orders may be excluded).
- List all customers, showing order info where it exists and
NULLotherwise. - Find customers who have never placed an order.
- Show each customer's name, order count, and total spent, including those who spent 0.
๐ก Hint
Q1 is an INNER JOIN. Q2 is a LEFT JOIN. Q3 is LEFT JOIN โฆ WHERE o.order_id IS NULL. Q4 is a LEFT JOIN with GROUP BY, COUNT, and COALESCE(SUM(...),0).
โ Solution
-- 1
SELECT c.name, o.order_id, o.amount
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id;
-- 2
SELECT c.name, o.order_id, o.amount
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;
-- 3
SELECT c.customer_id, c.name
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL; -- returns Alice
-- 4
SELECT c.name,
COUNT(o.order_id) AS order_count,
COALESCE(SUM(o.amount), 0) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name
ORDER BY total_spent DESC;
Best Practices
โ Do
- Always specify the join type (
INNER/LEFT) explicitly โ clarity beats defaults. - Qualify columns with table aliases so
customer_idis never ambiguous. - Join on indexed keys (usually PK/FK columns) for performance.
- Aggregate with
GROUP BYwhen a one-to-many join produces repeated rows you don't want.
โ Don't
- Don't use the old comma-join syntax (
FROM a, b WHERE a.id = b.id) โ the explicitJOIN โฆ ONform is clearer and safer. - Don't filter a
LEFT JOIN's right table inWHEREif you meant to keep unmatched rows โ use theONclause. - Don't forget the
ONcondition, or you'll get an accidentalCROSS JOIN.
Summary & Quiz
๐ Key Takeaways
- Relational data is split into focused tables linked by primary and foreign keys.
- Relationships come in three shapes: 1:1, 1:N, and N:M (the last needs a junction table).
INNER JOINkeeps only matches;LEFT JOINkeeps all left rows and NULL-fills the rest.FULL OUTER,CROSS, andSELFjoins cover reconciliation, combinations, and hierarchies.- Fix duplicates with
GROUP BY; fix missing rows by choosing the right join (and filtering inON, notWHERE).
๐ฏ Quick Quiz
Question 1: A customer exists but has no orders. Which join type will still include that customer (with NULLs) when joining customers to orders?
Question 2: How do you model a many-to-many relationship between students and courses?
Question 3: Your LEFT JOIN was supposed to keep unmatched customers, but adding WHERE o.amount > 100 made them disappear. Why?
๐ Further Reading
๐ What's Next?
You can now query across a whole schema. Next you'll get hands-on with a specific engine in MySQL Setup and Administration โ installing, configuring, and managing a real MySQL server.