The heart of relational databases. Link customers to orders, products to categories — JOINs make your data come alive!
JOINs are all about combining data from two or more tables. So we need at least two tables to play with! We'll create a customers table and an orders table, and use them throughout the entire lesson.
Imagine a school. One notebook has the student roster (names, ages, classes). Another notebook has the library checkout log (which student borrowed which book). The checkout log only writes down the student's ID number, not their whole name. To find out "Who borrowed Harry Potter?" you have to look at BOTH notebooks — match the ID from the checkout log to the ID in the roster. That's exactly what a JOIN does! It matches rows across two tables using a shared column.
Run this SQL to create and populate both tables:
-- ========================================== -- TABLE 1: customers -- ========================================== CREATE TABLE customers ( id SERIAL PRIMARY KEY, name VARCHAR(100), city VARCHAR(50), email VARCHAR(100) ); INSERT INTO customers (name, city, email) VALUES ('Alice Johnson', 'New York', 'alice@example.com'), ('Bob Smith', 'Los Angeles', 'bob@example.com'), ('Charlie Brown', 'Chicago', 'charlie@example.com'), ('Diana Prince', 'Houston', 'diana@example.com'), ('Eve Williams', 'Phoenix', 'eve@example.com'), ('Frank Miller', 'New York', 'frank@example.com'), ('Grace Lee', 'San Francisco', 'grace@example.com'), ('Hank Davis', 'Seattle', 'hank@example.com'); -- ========================================== -- TABLE 2: orders -- ========================================== CREATE TABLE orders ( id SERIAL PRIMARY KEY, customer_id INTEGER REFERENCES customers(id), product VARCHAR(100), amount DECIMAL(10,2), order_date DATE ); INSERT INTO orders (customer_id, product, amount, order_date) VALUES (1, 'Laptop', 1200.00, '2024-01-15'), (1, 'Mouse', 25.99, '2024-01-15'), (2, 'Headphones', 79.99, '2024-02-03'), (3, 'Keyboard', 149.99, '2024-02-10'), (3, 'Monitor', 399.99, '2024-02-10'), (3, 'USB Hub', 34.99, '2024-03-01'), (1, 'Webcam', 89.99, '2024-03-05'), (4, 'Tablet', 499.99, '2024-03-12'), (5, 'Phone Case', 19.99, '2024-03-20'), (5, 'Screen Protector', 9.99, '2024-03-20'), (2, 'Laptop Stand', 59.99, '2024-04-01'), (4, 'Stylus Pen', 29.99, '2024-04-05');
Here's what our customers table looks like:
| id | name | city | |
|---|---|---|---|
| 1 | Alice Johnson | New York | alice@example.com |
| 2 | Bob Smith | Los Angeles | bob@example.com |
| 3 | Charlie Brown | Chicago | charlie@example.com |
| 4 | Diana Prince | Houston | diana@example.com |
| 5 | Eve Williams | Phoenix | eve@example.com |
| 6 | Frank Miller | New York | frank@example.com |
| 7 | Grace Lee | San Francisco | grace@example.com |
| 8 | Hank Davis | Seattle | hank@example.com |
And here's the orders table:
| id | customer_id | product | amount | order_date |
|---|---|---|---|---|
| 1 | 1 | Laptop | 1200.00 | 2024-01-15 |
| 2 | 1 | Mouse | 25.99 | 2024-01-15 |
| 3 | 2 | Headphones | 79.99 | 2024-02-03 |
| 4 | 3 | Keyboard | 149.99 | 2024-02-10 |
| 5 | 3 | Monitor | 399.99 | 2024-02-10 |
| 6 | 3 | USB Hub | 34.99 | 2024-03-01 |
| 7 | 1 | Webcam | 89.99 | 2024-03-05 |
| 8 | 4 | Tablet | 499.99 | 2024-03-12 |
| 9 | 5 | Phone Case | 19.99 | 2024-03-20 |
| 10 | 5 | Screen Protector | 9.99 | 2024-03-20 |
| 11 | 2 | Laptop Stand | 59.99 | 2024-04-01 |
| 12 | 4 | Stylus Pen | 29.99 | 2024-04-05 |
The customer_id column in the orders table points to the id column in the customers table. This is called a foreign key. Also notice: customers 6, 7, and 8 (Frank, Grace, Hank) have no orders at all. We did this on purpose — these "orphan" customers are critical for understanding how different JOIN types behave!
You work at a pizza shop. You have two lists taped to the wall:
List A (Orders): "Order #101 → Customer #7, Pepperoni Pizza." "Order #102 → Customer #3, Margherita Pizza."
List B (Customers): "Customer #7 = Sarah." "Customer #3 = Mike."
A delivery driver walks in and asks: "Who ordered the Pepperoni?" You can't answer from List A alone — it only says "Customer #7." You need to look up #7 on List B to find out it's Sarah. That's a JOIN! You're combining information from two separate lists using a matching ID.
In a relational database, data is split across multiple tables to avoid repetition. Instead of writing "Alice Johnson, New York, alice@example.com" on every single order, we just write her ID number (1). This saves space and keeps data consistent — if Alice moves to a new city, we update one row in the customers table instead of hundreds of order rows.
But this means to get a complete picture, we need to combine the tables back together. That's where JOINs come in.
If you can't combine tables, your data is locked in separate silos. You can see what was ordered but not who ordered it. You can see who your customers are but not what they bought. JOINs are the single most important concept in SQL. Master this and you can handle 90% of real-world database queries.
Your teacher says: "Everyone find a buddy! If you don't have a buddy, you sit out." INNER JOIN works the same way. It looks at both tables and says: "Only show me rows where there's a match in BOTH tables." Customers who never placed an order? They sit out. Orders that (somehow) don't have a valid customer? They sit out too. Only matched pairs make it into the result.
Think of a Venn diagram — INNER JOIN gives you only the overlapping part in the middle.
SELECT columns FROM table_a INNER JOIN table_b ON table_a.column = table_b.column;
The ON clause tells PostgreSQL how to match rows. It's like saying: "The customer_id in the orders table should match the id in the customers table."
SELECT c.name, o.product, o.amount, o.order_date FROM customers c INNER JOIN orders o ON c.id = o.customer_id ORDER BY c.name, o.order_date;
Result:
| name | product | amount | order_date |
|---|---|---|---|
| Alice Johnson | Laptop | 1200.00 | 2024-01-15 |
| Alice Johnson | Mouse | 25.99 | 2024-01-15 |
| Alice Johnson | Webcam | 89.99 | 2024-03-05 |
| Bob Smith | Headphones | 79.99 | 2024-02-03 |
| Bob Smith | Laptop Stand | 59.99 | 2024-04-01 |
| Charlie Brown | Keyboard | 149.99 | 2024-02-10 |
| Charlie Brown | Monitor | 399.99 | 2024-02-10 |
| Charlie Brown | USB Hub | 34.99 | 2024-03-01 |
| Diana Prince | Tablet | 499.99 | 2024-03-12 |
| Diana Prince | Stylus Pen | 29.99 | 2024-04-05 |
| Eve Williams | Phone Case | 19.99 | 2024-03-20 |
| Eve Williams | Screen Protector | 9.99 | 2024-03-20 |
Notice they're completely missing from the results! They exist in the customers table but have no matching rows in the orders table. INNER JOIN silently drops them. This is the #1 gotcha with INNER JOIN — you might lose data you didn't expect to lose. If you need to keep ALL customers (even those without orders), you need a LEFT JOIN instead.
SELECT c.name, COUNT(o.id) AS total_orders, SUM(o.amount) AS total_spent FROM customers c INNER JOIN orders o ON c.id = o.customer_id GROUP BY c.name ORDER BY total_spent DESC;
Result:
| name | total_orders | total_spent |
|---|---|---|
| Alice Johnson | 3 | 1315.98 |
| Charlie Brown | 3 | 584.97 |
| Diana Prince | 2 | 529.98 |
| Bob Smith | 2 | 139.98 |
| Eve Williams | 2 | 29.98 |
Notice how we wrote FROM customers c instead of FROM customers? The c is an alias — a short nickname for the table. Then instead of writing customers.name everywhere, we just write c.name. When you're joining multiple tables, aliases go from "nice to have" to "absolutely essential" for readability.
INNER JOIN is like a dance partner matchup. Only people who have a dance partner get on the floor. If you showed up alone, you sit on the sidelines — no matter how good a dancer you are. Both the lead and the follow must be present for a pair to appear in the results.
It's school photo day. The rule is: every student gets their photo taken, no exceptions. If a student brought a pet for "Pets & People Day," great — the pet is in the photo too! If a student didn't bring a pet, they still get their photo taken — just alone, with an empty spot where the pet would be. That empty spot is NULL.
LEFT JOIN means: "Keep EVERY row from the left table (customers), and if there's a match on the right table (orders), include it. If there's no match, fill in NULLs."
SELECT columns FROM left_table LEFT JOIN right_table ON left_table.column = right_table.column;
SELECT c.name, c.city, o.product, o.amount FROM customers c LEFT JOIN orders o ON c.id = o.customer_id ORDER BY c.id, o.id;
Result:
| name | city | product | amount |
|---|---|---|---|
| Alice Johnson | New York | Laptop | 1200.00 |
| Alice Johnson | New York | Mouse | 25.99 |
| Alice Johnson | New York | Webcam | 89.99 |
| Bob Smith | Los Angeles | Headphones | 79.99 |
| Bob Smith | Los Angeles | Laptop Stand | 59.99 |
| Charlie Brown | Chicago | Keyboard | 149.99 |
| Charlie Brown | Chicago | Monitor | 399.99 |
| Charlie Brown | Chicago | USB Hub | 34.99 |
| Diana Prince | Houston | Tablet | 499.99 |
| Diana Prince | Houston | Stylus Pen | 29.99 |
| Eve Williams | Phoenix | Phone Case | 19.99 |
| Eve Williams | Phoenix | Screen Protector | 9.99 |
| Frank Miller | New York | NULL | NULL |
| Grace Lee | San Francisco | NULL | NULL |
| Hank Davis | Seattle | NULL | NULL |
Frank, Grace, and Hank are back! They appear in the results with NULL for product and amount because they have no orders. This is the magic of LEFT JOIN.
-- LEFT JOIN + WHERE ... IS NULL = "find unmatched rows" SELECT c.name, c.email FROM customers c LEFT JOIN orders o ON c.id = o.customer_id WHERE o.id IS NULL;
Result:
| name | |
|---|---|
| Frank Miller | frank@example.com |
| Grace Lee | grace@example.com |
| Hank Davis | hank@example.com |
This pattern (LEFT JOIN + IS NULL) is incredibly common in the real world. E-commerce companies use it to find "registered users who never purchased anything" so they can send them a discount code. SaaS companies use it to find "signed-up users who never activated their account." It's one of the most powerful patterns in SQL!
LEFT JOIN and LEFT OUTER JOIN are exactly the same thing. The word "OUTER" is optional. Most people drop it for brevity. Same for RIGHT JOIN / RIGHT OUTER JOIN and FULL JOIN / FULL OUTER JOIN.
RIGHT JOIN is just LEFT JOIN flipped around. Instead of keeping all rows from the left table, it keeps all rows from the right table. It's like looking at a LEFT JOIN in a mirror.
Imagine the same school photo scenario, but now the rule is: "Every PET gets photographed, even if their owner didn't show up." The pets are the right table — they all appear. Owners who are there get in the photo; missing owners become NULL.
SELECT c.name, o.product, o.amount FROM customers c RIGHT JOIN orders o ON c.id = o.customer_id ORDER BY o.id;
Result: Since every order in our data has a valid customer_id, this produces the same 12 rows as INNER JOIN. RIGHT JOIN becomes different when the right table has rows that don't match the left table (for example, if an order had customer_id = 99 and no customer #99 existed).
You can always rewrite a RIGHT JOIN as a LEFT JOIN by simply swapping the table order. Most SQL developers strongly prefer LEFT JOIN because it reads more naturally: "Start with the main table, then optionally attach related data." If you see a RIGHT JOIN in someone's code, you can mentally convert it: A RIGHT JOIN B is identical to B LEFT JOIN A.
-- These two queries produce IDENTICAL results: -- Using RIGHT JOIN SELECT c.name, o.product FROM customers c RIGHT JOIN orders o ON c.id = o.customer_id; -- Same thing as LEFT JOIN with tables swapped SELECT c.name, o.product FROM orders o LEFT JOIN customers c ON c.id = o.customer_id;
You and your friend are both throwing parties on the same day. You each have a guest list. Now you want to see everyone across both lists. Some people are on both lists (they were invited to both parties). Some are only on your list. Some are only on your friend's list. A FULL OUTER JOIN shows ALL of them — nobody gets left out.
For the people only on one list, the columns from the other list show up as NULL. It's the most inclusive JOIN type.
SELECT columns FROM table_a FULL OUTER JOIN table_b ON table_a.column = table_b.column;
SELECT c.name, c.city, o.product, o.amount FROM customers c FULL OUTER JOIN orders o ON c.id = o.customer_id ORDER BY c.id NULLS LAST, o.id NULLS LAST;
Result: All 12 matched rows (customers with orders) PLUS the 3 unmatched customers (Frank, Grace, Hank with NULL products). If we had "orphan" orders that didn't match any customer, they'd appear too with NULL names.
| name | city | product | amount |
|---|---|---|---|
| Alice Johnson | New York | Laptop | 1200.00 |
| Alice Johnson | New York | Mouse | 25.99 |
| Alice Johnson | New York | Webcam | 89.99 |
| Bob Smith | Los Angeles | Headphones | 79.99 |
| Bob Smith | Los Angeles | Laptop Stand | 59.99 |
| Charlie Brown | Chicago | Keyboard | 149.99 |
| Charlie Brown | Chicago | Monitor | 399.99 |
| Charlie Brown | Chicago | USB Hub | 34.99 |
| Diana Prince | Houston | Tablet | 499.99 |
| Diana Prince | Houston | Stylus Pen | 29.99 |
| Eve Williams | Phoenix | Phone Case | 19.99 |
| Eve Williams | Phoenix | Screen Protector | 9.99 |
| Frank Miller | New York | NULL | NULL |
| Grace Lee | San Francisco | NULL | NULL |
| Hank Davis | Seattle | NULL | NULL |
FULL OUTER JOIN is like a complete census. Imagine merging a "tax filers" list with a "property owners" list. Some people filed taxes AND own property (matched). Some filed taxes but don't own property (left only). Some own property but didn't file taxes (right only). A FULL OUTER JOIN gives you the complete picture of all citizens, regardless of which list they're on.
FULL OUTER JOIN is less common than INNER or LEFT JOIN, but it's invaluable for data reconciliation. For example: comparing two data sources to find discrepancies, merging old and new systems, or finding records that exist in one system but not the other.
Imagine you sell T-shirts in 3 sizes (S, M, L) and 4 colors (Red, Blue, Green, Black). You want to list every possible combination: S-Red, S-Blue, S-Green, S-Black, M-Red, M-Blue... and so on. That's 3 × 4 = 12 combinations. A CROSS JOIN does exactly this — it pairs every row from table A with every row from table B.
SELECT columns FROM table_a CROSS JOIN table_b;
Notice: no ON clause! There's no matching condition because every row is paired with every other row.
-- Create two tiny helper tables for this demo SELECT size, color FROM (VALUES ('S'), ('M'), ('L')) AS sizes(size) CROSS JOIN (VALUES ('Red'), ('Blue'), ('Green'), ('Black')) AS colors(color);
Result (12 rows):
| size | color |
|---|---|
| S | Red |
| S | Blue |
| S | Green |
| S | Black |
| M | Red |
| M | Blue |
| M | Green |
| M | Black |
| L | Red |
| L | Blue |
| L | Green |
| L | Black |
CROSS JOIN multiplies the row counts. If table A has 1,000 rows and table B has 1,000 rows, the result has 1,000,000 rows! With 10,000 × 10,000 tables, you get 100 million rows. This can crash your database or eat all your server's memory. Always think carefully before using CROSS JOIN, and use it only with small tables.
CROSS JOIN is like a round-robin tournament where every team plays against every other team. If you have 8 teams, that's 8 × 8 = 64 matchups (though some might be self-matches). In real-world SQL, CROSS JOINs are used for things like generating date calendars, building configuration matrices, or creating lookup grids.
Your company has an employees table. Each employee has a manager_id that points to another employee in the same table. To find out "who manages whom," you need to join the employees table to itself — comparing each employee's manager_id to another employee's id.
It's like looking at the same phone book twice — once to find yourself and once to find your boss.
CREATE TABLE employees ( id SERIAL PRIMARY KEY, name VARCHAR(100), role VARCHAR(50), manager_id INTEGER REFERENCES employees(id) ); INSERT INTO employees (name, role, manager_id) VALUES ('Sara CEO', 'CEO', NULL), ('Tom VP', 'VP Engineering', 1), ('Lisa VP', 'VP Marketing', 1), ('Jake Developer', 'Senior Developer', 2), ('Amy Designer', 'UI Designer', 2), ('Max Marketer', 'Content Writer', 3);
SELECT e.name AS employee, e.role, m.name AS manager FROM employees e LEFT JOIN employees m ON e.manager_id = m.id ORDER BY e.id;
Result:
| employee | role | manager |
|---|---|---|
| Sara CEO | CEO | NULL |
| Tom VP | VP Engineering | Sara CEO |
| Lisa VP | VP Marketing | Sara CEO |
| Jake Developer | Senior Developer | Tom VP |
| Amy Designer | UI Designer | Tom VP |
| Max Marketer | Content Writer | Lisa VP |
Notice how the same table appears twice in the query — once as e (the employee) and once as m (the manager). Aliases are mandatory here; without them, PostgreSQL wouldn't know which "employees" you're referring to!
We used LEFT JOIN instead of INNER JOIN because the CEO (Sara) has manager_id = NULL — she doesn't report to anyone. An INNER JOIN would drop her from the results. LEFT JOIN keeps her and shows NULL for the manager column.
Think of it like a family tree. To find out "What did Grandma's daughter's son order for lunch?" you need to go through three connections: Grandma → Daughter → Grandson → Lunch Order. In SQL, you chain JOINs together to follow these relationships from one table to the next to the next.
CREATE TABLE categories ( id SERIAL PRIMARY KEY, name VARCHAR(50) ); INSERT INTO categories (name) VALUES ('Computers'), ('Accessories'), ('Mobile'); -- Add a category_id column to our orders table ALTER TABLE orders ADD COLUMN category_id INTEGER REFERENCES categories(id); UPDATE orders SET category_id = 1 WHERE product IN ('Laptop', 'Monitor'); UPDATE orders SET category_id = 2 WHERE product IN ('Mouse', 'Keyboard', 'USB Hub', 'Webcam', 'Headphones', 'Laptop Stand'); UPDATE orders SET category_id = 3 WHERE product IN ('Tablet', 'Phone Case', 'Screen Protector', 'Stylus Pen');
SELECT c.name AS customer, o.product, o.amount, cat.name AS category FROM customers c INNER JOIN orders o ON c.id = o.customer_id INNER JOIN categories cat ON o.category_id = cat.id ORDER BY cat.name, o.amount DESC;
Result:
| customer | product | amount | category |
|---|---|---|---|
| Bob Smith | Headphones | 79.99 | Accessories |
| Bob Smith | Laptop Stand | 59.99 | Accessories |
| Charlie Brown | Keyboard | 149.99 | Accessories |
| Alice Johnson | Webcam | 89.99 | Accessories |
| Charlie Brown | USB Hub | 34.99 | Accessories |
| Alice Johnson | Mouse | 25.99 | Accessories |
| Alice Johnson | Laptop | 1200.00 | Computers |
| Charlie Brown | Monitor | 399.99 | Computers |
| Diana Prince | Tablet | 499.99 | Mobile |
| Diana Prince | Stylus Pen | 29.99 | Mobile |
| Eve Williams | Phone Case | 19.99 | Mobile |
| Eve Williams | Screen Protector | 9.99 | Mobile |
-- Total revenue by category, showing who the top spender was SELECT cat.name AS category, COUNT(o.id) AS order_count, SUM(o.amount) AS total_revenue FROM categories cat INNER JOIN orders o ON cat.id = o.category_id INNER JOIN customers c ON o.customer_id = c.id GROUP BY cat.name ORDER BY total_revenue DESC;
Result:
| category | order_count | total_revenue |
|---|---|---|
| Computers | 2 | 1599.99 |
| Mobile | 4 | 559.96 |
| Accessories | 6 | 440.94 |
A common mistake is forgetting the ON for one of the JOINs. Each JOIN must specify exactly how to match rows. Think of it as a chain: Table A links to Table B on one column, then Table B links to Table C on a different column. Every link in the chain needs its own rule.
Let's put all the JOIN types together so you can see the big picture. Using our 8 customers and 12 orders data:
| JOIN Type | What It Keeps | Result Rows | NULLs Appear? |
|---|---|---|---|
| INNER JOIN | Only rows that match in BOTH tables | 12 | No (unmatched rows are dropped) |
| LEFT JOIN | ALL rows from left + matches from right | 15 | Yes — right columns are NULL for unmatched left rows |
| RIGHT JOIN | ALL rows from right + matches from left | 12 | Yes — left columns are NULL for unmatched right rows |
| FULL OUTER JOIN | ALL rows from BOTH tables | 15 | Yes — on both sides for unmatched rows |
| CROSS JOIN | Every row × every row (no ON clause) | 96 (8 × 12) | No — every combination is produced |
Picture two overlapping circles. The left circle is the customers table. The right circle is the orders table. The overlap in the middle is where a customer has an order.
INNER JOIN = Only the overlap (middle part).
LEFT JOIN = The entire left circle + the overlap.
RIGHT JOIN = The overlap + the entire right circle.
FULL OUTER JOIN = Both entire circles, everything.
CROSS JOIN = Doesn't use a Venn diagram at all — it's a completely different concept (every possible pair).
80% of the time: INNER JOIN or LEFT JOIN. Start with INNER JOIN when you only want matched data. Switch to LEFT JOIN when you need to keep all rows from the "main" table. Use FULL OUTER JOIN for data reconciliation. Avoid CROSS JOIN unless you specifically need all combinations. Self JOIN when you're looking at hierarchical data within the same table.
JOINs are powerful but tricky. Here are the mistakes that trip up beginners (and even experienced developers) the most.
-- ❌ WRONG — No ON clause turns this into a CROSS JOIN! SELECT c.name, o.product FROM customers c INNER JOIN orders o; -- Result: 8 × 12 = 96 rows of nonsense! -- ✅ CORRECT — Always include ON SELECT c.name, o.product FROM customers c INNER JOIN orders o ON c.id = o.customer_id;
-- ❌ WRONG — Joining customer.id to order.id (both are just sequential numbers) SELECT c.name, o.product FROM customers c INNER JOIN orders o ON c.id = o.id; -- This matches customer #1 with order #1, customer #2 with order #2 -- That's completely WRONG — it's just coincidental number matching! -- ✅ CORRECT — Join on the foreign key relationship SELECT c.name, o.product FROM customers c INNER JOIN orders o ON c.id = o.customer_id;
-- ❌ WRONG — Both tables have an "id" column. Which one do you mean? SELECT id, name, product FROM customers c INNER JOIN orders o ON c.id = o.customer_id; -- ERROR: column reference "id" is ambiguous -- ✅ CORRECT — Always prefix with the table alias SELECT c.id AS customer_id, c.name, o.product FROM customers c INNER JOIN orders o ON c.id = o.customer_id;
This happens when you join on a condition that matches too many rows, like joining on a column that has the same value in many rows (e.g., city = city).
-- ⚠️ DANGEROUS — Joining on city creates duplicates SELECT c1.name, c2.name FROM customers c1 INNER JOIN customers c2 ON c1.city = c2.city WHERE c1.id <> c2.id; -- This finds customers in the SAME city -- Alice & Frank both live in New York, so they'll match
If your report says "Show ALL customers and their order totals" but some customers have $0 in orders, an INNER JOIN will silently drop those customers. Always ask yourself: "Do I need rows even if there's no match?" If yes → LEFT JOIN.
Before writing a JOIN, answer these three questions: (1) Which table is my "main" table? (2) Which column connects the two tables? (3) Do I need rows from the main table even when there's no match? If yes → LEFT JOIN. If no → INNER JOIN.
Time to practice! Try solving these yourself using the customers and orders tables we created at the beginning. Click "Reveal Solution" after you've given it a try.
Write a query that shows each order with the customer's name and city. Include the product, amount, and order_date. Sort by order_date ascending.
SELECT c.name, c.city, o.product, o.amount, o.order_date FROM customers c INNER JOIN orders o ON c.id = o.customer_id ORDER BY o.order_date ASC;
Find all customers who have never placed an order. Show their name, city, and email.
SELECT c.name, c.city, c.email FROM customers c LEFT JOIN orders o ON c.id = o.customer_id WHERE o.id IS NULL;
Returns: Frank Miller, Grace Lee, Hank Davis
Find the customer who has spent the most money in total. Show their name, total number of orders, and total amount spent.
SELECT c.name, COUNT(o.id) AS total_orders, SUM(o.amount) AS total_spent FROM customers c INNER JOIN orders o ON c.id = o.customer_id GROUP BY c.name ORDER BY total_spent DESC LIMIT 1;
Returns: Alice Johnson — 3 orders, $1,315.98 total
Show ALL customers with their total spending. Customers with no orders should show 0 for total_spent and 0 for order_count. Sort by total_spent descending. (Hint: use COALESCE!)
SELECT c.name, COUNT(o.id) AS order_count, COALESCE(SUM(o.amount), 0) AS total_spent FROM customers c LEFT JOIN orders o ON c.id = o.customer_id GROUP BY c.name ORDER BY total_spent DESC;
For each city, show the number of customers, the number of orders placed, and the total revenue. Include cities with zero orders. Sort by total revenue descending.
SELECT c.city, COUNT(DISTINCT c.id) AS customer_count, COUNT(o.id) AS order_count, COALESCE(SUM(o.amount), 0) AS total_revenue FROM customers c LEFT JOIN orders o ON c.id = o.customer_id GROUP BY c.city ORDER BY total_revenue DESC;
Using the employees table, find all employees whose manager is a VP (role contains 'VP'). Show the employee name, their role, and their manager's name.
SELECT e.name AS employee, e.role, m.name AS manager FROM employees e INNER JOIN employees m ON e.manager_id = m.id WHERE m.role ILIKE '%VP%';
Returns: Jake Developer (managed by Tom VP), Amy Designer (managed by Tom VP), Max Marketer (managed by Lisa VP)
Congratulations! You've conquered JOINs — the single most important concept in relational databases. Here's a quick recap:
| JOIN Type | When to Use | Key Syntax |
|---|---|---|
| INNER JOIN | Only want matched rows | FROM a INNER JOIN b ON a.id = b.a_id |
| LEFT JOIN | Keep all rows from the main table | FROM a LEFT JOIN b ON a.id = b.a_id |
| RIGHT JOIN | Keep all from the second table (rare) | FROM a RIGHT JOIN b ON a.id = b.a_id |
| FULL OUTER JOIN | Keep everything from both | FROM a FULL OUTER JOIN b ON ... |
| CROSS JOIN | Every possible combination | FROM a CROSS JOIN b |
| Self JOIN | Compare rows within the same table | FROM a x JOIN a y ON x.col = y.col |
| Multi-Table JOIN | Chain 3+ tables together | FROM a JOIN b ON ... JOIN c ON ... |
Now that you can combine tables, it's time to learn about queries inside queries! Subqueries let you use the result of one query as input to another — like a Russian nesting doll of SQL. And CTEs (Common Table Expressions) let you name and reuse complex query parts, making your SQL cleaner and more readable. Think of it as going from "one big messy query" to "organized, modular building blocks."