1. INNER JOIN
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
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
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.
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."
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
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
Build a blog schema and query across it
Model authors, posts and comments, then write joins answering real questions about them.
Requirements:
- Create
authors,posts(with anauthor_idforeign key) andcomments(with apost_idforeign key) tables, and insert enough data that at least one author has no posts and at least one post has no comments. - A query listing every post with its author's name, using an appropriate join.
- A query finding every author who has never published a post, using
LEFT JOINandIS NULL. - A query joining all three tables to list every comment along with its post's title and the post's author's name.
- 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.
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?
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?
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?
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?
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.