Week 3: Joins — INNER, LEFT, RIGHT, FULL & Self Joins

Every table in a well-designed database is deliberately incomplete on its own — Week 1 stored a customer's user_id in orders instead of repeating their name and email. This week is where that design pays off: joins are how SQL puts related data back together across tables, and are genuinely the single most important skill in this entire course.

Module 3 of 10 Week 3 of 10 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Combine rows from two tables with INNER JOIN based on a matching key
  • Explain the difference between LEFT JOIN, RIGHT JOIN and INNER JOIN, and when each is correct
  • Use FULL OUTER JOIN and a self join for the cases INNER/LEFT JOIN can't express
  • Join three or more tables in a single query without duplicating or losing rows

1. INNER JOIN

setup — two related tables
CREATE TABLE customers (
    id   SERIAL PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE orders (
    id          SERIAL PRIMARY KEY,
    customer_id INT REFERENCES customers(id),
    total       NUMERIC(10, 2) NOT NULL
);

INSERT INTO customers (name) VALUES ('Asha'), ('Ravi'), ('Priya');
INSERT INTO orders (customer_id, total) VALUES (1, 49.99), (1, 12.50), (2, 99.00);
-- note: Priya (id 3) has no orders at all
an INNER JOIN
SELECT customers.name, orders.total
FROM customers
INNER JOIN orders ON customers.id = orders.customer_id;

--  name | total
-- ------+-------
--  Asha | 49.99
--  Asha | 12.50
--  Ravi | 99.00
-- (Priya doesn't appear — she has no matching row in orders)

INNER JOIN (often just written JOIN) returns only rows that have a match on both sides — Priya disappears entirely because there's no orders row with her customer_id. This is exactly correct behavior for "show me customers who've actually ordered something," and exactly wrong for "show me every customer, with orders if they have any."

2. LEFT JOIN & RIGHT JOIN

LEFT JOIN — every row from the left table, matched or not
SELECT customers.name, orders.total
FROM customers
LEFT JOIN orders ON customers.id = orders.customer_id;

--  name  | total
-- -------+-------
--  Asha  | 49.99
--  Asha  | 12.50
--  Ravi  | 99.00
--  Priya | NULL   -- kept, with NULL where there was no match

LEFT JOIN keeps every row from the table listed first (the "left" table), filling in NULL for columns from the right table wherever there's no match. RIGHT JOIN is the mirror image — every row from the table listed second — and in practice is rarely used, since swapping the table order and writing LEFT JOIN instead expresses the same query more conventionally.

a genuinely common use — finding customers with NO orders
SELECT customers.name
FROM customers
LEFT JOIN orders ON customers.id = orders.customer_id
WHERE orders.id IS NULL; -- only rows where the LEFT JOIN found nothing to match

3. FULL OUTER JOIN & Self Joins

FULL OUTER JOIN keeps every row from both tables, matched or not — rarer in practice than LEFT JOIN, but the right tool for "show me everything from both sides, and where they don't line up."

a self join — comparing a table to itself
CREATE TABLE employees (
    id        SERIAL PRIMARY KEY,
    name      TEXT NOT NULL,
    manager_id INT REFERENCES employees(id)
);

-- who reports to whom?
SELECT emp.name AS employee, mgr.name AS manager
FROM employees emp
LEFT JOIN employees mgr ON emp.manager_id = mgr.id;

A self join is just a normal join where a table is joined to itself, using two different aliases (emp and mgr above) so SQL can tell the two "copies" apart — the only genuinely new idea here is the aliasing, not the join mechanics.

4. Joining Three or More Tables

a three-table join — customers, orders & order items
CREATE TABLE order_items (
    id       SERIAL PRIMARY KEY,
    order_id INT REFERENCES orders(id),
    product  TEXT NOT NULL,
    quantity INT NOT NULL
);

SELECT customers.name, orders.id AS order_id, order_items.product, order_items.quantity
FROM customers
JOIN orders ON customers.id = orders.customer_id
JOIN order_items ON orders.id = order_items.order_id
WHERE customers.name = 'Asha';

Each additional JOIN narrows or extends the row set one step at a time — read it top to bottom as "start with customers, bring in their orders, then bring in each order's items." A common mistake once queries get this deep is an accidental many-to-many fan-out (a customer with 3 orders, each with 4 items, produces 12 rows) — not a bug, just something to account for when aggregating on top of a multi-table join.

5. Hands-on Exercise

Hands-on

Build a blog schema and query across it

Model authors, posts and comments, then write joins answering real questions about them.

Requirements:

  1. Create authors, posts (with an author_id foreign key) and comments (with a post_id foreign key) tables, and insert enough data that at least one author has no posts and at least one post has no comments.
  2. A query listing every post with its author's name, using an appropriate join.
  3. A query finding every author who has never published a post, using LEFT JOIN and IS NULL.
  4. A query joining all three tables to list every comment along with its post's title and the post's author's name.
  5. A query using GROUP BY (from Week 2) on top of a join to count how many posts each author has written, including authors with zero posts.
Hint

If the last exercise's author-with-zero-posts count doesn't include an author with genuinely no posts, check that you're using LEFT JOIN (not JOIN) from authors to posts, and that you're using COUNT(posts.id) rather than COUNT(*)COUNT(*) counts the one NULL-filled row LEFT JOIN produces for that author as 1, not 0, while COUNT(posts.id) correctly skips it since the id itself is NULL.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does Priya disappear entirely from an INNER JOIN between customers and orders, even though she's a real row in the customers table?

INNER JOIN only returns rows that have a match on both sides of the join condition — Priya has no corresponding row in orders, so there's nothing for her customers row to match with, and the entire row is excluded from the result. This is correct when the question is specifically about customers who have ordered something.

Q2

Why does a LEFT JOIN followed by WHERE right_table.id IS NULL correctly find rows with no match, when a plain INNER JOIN never could?

LEFT JOIN keeps every row from the left table regardless of a match, filling the right table's columns with NULL when there's none — filtering for exactly those NULLs afterward isolates the left-table rows that had no counterpart at all. INNER JOIN would have already discarded those rows before any WHERE clause could even look for them.

Q3

What is the only genuinely new concept in a self join, compared to joining two different tables?

Using two different aliases for the same table so SQL (and the query's author) can distinguish which "copy" of the table a given column reference belongs to — the join mechanics themselves (matching rows on a condition) are identical to joining any two separate tables; the table just happens to be the same one on both sides.

Q4

In a three-table join from customers to orders to order_items, why might a customer with 3 orders of 4 items each produce 12 result rows instead of 3?

Each join step multiplies out every matching combination — 3 orders each joined against their own 4 items produces 3 × 4 = 12 combined rows, one per order-item pairing, which is the correct and expected behavior of a join, not a bug. It's something to explicitly account for (usually with GROUP BY or a subquery) whenever aggregating a value like a total on top of a join that fans out this way.