Skip to main content

๐Ÿ”— 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 JOIN and LEFT JOIN queries and predict exactly which rows each returns
  • Explain RIGHT JOIN, FULL OUTER JOIN, CROSS JOIN, and SELF 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:

erDiagram USER ||--|| PROFILE : "has one (1:1)" CUSTOMER ||--o{ ORDER : "places many (1:N)" STUDENT ||--o{ ENROLLMENT : "" COURSE ||--o{ ENROLLMENT : ""
  • One-to-One (1:1): each row on one side matches exactly one on the other. Example: a user and their profile. 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 customer has many orders; 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: students and courses. SQL can't store this directly, so you add a junction table (here enrollment) holding a FK to each side.
The three relationship types with keys One-to-one links user to profile; one-to-many links customer to many orders; many-to-many links students and courses through an enrollment junction table. One-to-One Useruser_id PK Profileuser_id FK One-to-Many Customercustomer_id PK Order ยท FK Order ยท FK Many-to-Many (via junction) Studentstudent_id PK Enrollmentstudent_id + course_id FK Coursecourse_id PK
Figure 1 โ€” The foreign key sits on the "many" side. A many-to-many relationship is really two one-to-many relationships meeting at a junction table.

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_idname
1John Smith
2Jane Doe
3Bob Johnson
4Alice Brown
orders
order_idcustomer_idamount
1011150.50
102189.99
103249.95
104375.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_idnameorder_idamount
1John Smith101150.50
1John Smith10289.99
2Jane Doe10349.95
3Bob Johnson10475.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_idnameorder_idamount
1John Smith101150.50
1John Smith10289.99
2Jane Doe10349.95
3Bob Johnson10475.00
4Alice BrownNULLNULL
INNER JOIN versus LEFT JOIN as set diagrams INNER JOIN returns only the overlapping region of tables A and B; LEFT JOIN returns all of A plus the overlap with B. INNER JOIN A B only matches LEFT JOIN A B all of A + matches
Figure 2 โ€” 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:

erDiagram CUSTOMERS ||--o{ ORDERS : places ORDERS ||--o{ ORDER_DETAILS : contains PRODUCTS ||--o{ ORDER_DETAILS : "appears in"
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 JOIN in a chain can silently drop rows an earlier LEFT JOIN preserved.

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

  1. List every customer together with each of their orders (customers with no orders may be excluded).
  2. List all customers, showing order info where it exists and NULL otherwise.
  3. Find customers who have never placed an order.
  4. 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_id is never ambiguous.
  • Join on indexed keys (usually PK/FK columns) for performance.
  • Aggregate with GROUP BY when 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 explicit JOIN โ€ฆ ON form is clearer and safer.
  • Don't filter a LEFT JOIN's right table in WHERE if you meant to keep unmatched rows โ€” use the ON clause.
  • Don't forget the ON condition, or you'll get an accidental CROSS 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 JOIN keeps only matches; LEFT JOIN keeps all left rows and NULL-fills the rest.
  • FULL OUTER, CROSS, and SELF joins cover reconciliation, combinations, and hierarchies.
  • Fix duplicates with GROUP BY; fix missing rows by choosing the right join (and filtering in ON, not WHERE).

๐ŸŽฏ 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.