INNER JOIN Fundamentals
Progress Tracking
Log in to save this lesson and continue from where you left off.
Where the Real Work Begins
If you’ve been working with single tables, you’ve only seen a fraction of what SQL can do. In practice, almost every meaningful question requires pulling data from multiple tables:
- “Show me orders with customer names.”
- “Get products with their category labels.”
- “Find employees with their department info.”
All of these need JOINs.
INNER JOIN is the workhorse. You’ll use it constantly. It connects rows from two tables where a condition is met, and drops everything that doesn’t match.
The Basic Pattern
Here’s the syntax you’ll write hundreds of times:
SELECT columns
FROM table1
INNER JOIN table2
ON table1.column = table2.column;The ON clause is where you specify how the tables connect. In real databases, this is almost always a foreign key pointing to a primary key — exactly the relationships covered in Lesson 3.1.
A Realistic Example
Say your project manager asks: “Can you pull a list of orders with customer details? We need to send shipping notifications.”
| id | first_name | last_name | city | address | phone_number |
|---|---|---|---|---|---|
| 8 | John | Joseph | San Francisco | 928-386-8164 | |
| 7 | Jill | Michael | Austin | 813-297-0692 | |
| 4 | William | Daniel | Denver | 813-368-1200 | |
| 5 | Henry | Jackson | Miami | 808-601-7513 | |
| 13 | Emma | Isaac | Miami | 808-690-5201 |
| id | cust_id | order_date | order_details | total_order_cost |
|---|---|---|---|---|
| 1 | 3 | 2019-03-04 | Coat | 100 |
| 2 | 3 | 2019-03-01 | Shoes | 80 |
| 3 | 3 | 2019-03-07 | Skirt | 30 |
| 4 | 7 | 2019-02-01 | Coat | 25 |
| 5 | 7 | 2019-03-10 | Shoes | 80 |
Join orders to customers using cust_id. Select the order id, order_date, customer first_name, and city.
Orders live in one table, customer details in another. The JOIN brings them together. Only orders with valid customers show up.
You’ll see both INNER JOIN and just JOIN in the wild. They’re identical. INNER JOIN is preferred because it’s explicit about what’s happening, but your team might have a style preference.
Always Use Table Aliases
Notice the AS o and AS c? Those aliases aren’t optional in practice. When you’re joining 3, 4, 5 tables, you need short names or your query becomes unreadable.
-- This gets painful fast:
SELECT
orders.id,
customers.first_name,
products.name
FROM orders
INNER JOIN customers
ON orders.cust_id = customers.id
INNER JOIN products
ON orders.product_id = products.id;
-- Much better:
SELECT
o.id,
c.first_name,
p.name
FROM orders o
INNER JOIN customers c
ON o.cust_id = c.id
INNER JOIN products p
ON o.product_id = p.id;Pick meaningful aliases. ‘o’ for orders, ‘c’ for customers, ‘p’ for products.
What INNER JOIN Actually Does
Here’s the mental model: INNER JOIN finds matching pairs. If a row from the left table has no match in the right table, it’s gone. Same in reverse.
This matters. A lot. If you have orders that point to deleted customers (bad data happens), those orders won’t appear. If you have customers who never ordered, they won’t show up either.
Total Cost Of Orders
| id | first_name | last_name | city | address | phone_number |
|---|---|---|---|---|---|
| 8 | John | Joseph | San Francisco | 928-386-8164 | |
| 7 | Jill | Michael | Austin | 813-297-0692 | |
| 4 | William | Daniel | Denver | 813-368-1200 | |
| 5 | Henry | Jackson | Miami | 808-601-7513 | |
| 13 | Emma | Isaac | Miami | 808-690-5201 |
| id | cust_id | order_date | order_details | total_order_cost |
|---|---|---|---|---|
| 1 | 3 | 2019-03-04 | Coat | 100 |
| 2 | 3 | 2019-03-01 | Shoes | 80 |
| 3 | 3 | 2019-03-07 | Skirt | 30 |
| 4 | 7 | 2019-02-01 | Coat | 25 |
| 5 | 7 | 2019-03-10 | Shoes | 80 |
Find the total cost of each customer's orders. Output customer's id, first name, and the total order cost. Order records by customer's first name alphabetically.
Qualifying Column Names
When both tables have a column called id or name or created_at, you have to tell SQL which one you mean:
-- SQL doesn’t know which 'id' you want:
SELECT id -- Error: ambiguous column name
FROM orders
JOIN customers ON ...
-- Be explicit:
SELECT o.id
FROM orders o
JOIN customers c ON ...Even when a column name is unique, qualifying it anyway is good practice. It makes the query self-documenting. When someone reads o.order_date, they know exactly where that column lives.
Adding WHERE Clauses
JOINs and WHERE work together. The JOIN connects tables, then WHERE filters the combined result.
Add a WHERE clause to filter to orders over 100 from customers in the US.
Conceptually, first the tables get joined, then the combined rows get filtered. (The query optimizer might reorder things for performance, but the logical result is the same.)
Order Details
| id | first_name | last_name | city | address | phone_number |
|---|---|---|---|---|---|
| 8 | John | Joseph | San Francisco | 928-386-8164 | |
| 7 | Jill | Michael | Austin | 813-297-0692 | |
| 4 | William | Daniel | Denver | 813-368-1200 | |
| 5 | Henry | Jackson | Miami | 808-601-7513 | |
| 13 | Emma | Isaac | Miami | 808-690-5201 |
| id | cust_id | order_date | order_details | total_order_cost |
|---|---|---|---|---|
| 1 | 3 | 2019-03-04 | Coat | 100 |
| 2 | 3 | 2019-03-01 | Shoes | 80 |
| 3 | 3 | 2019-03-07 | Skirt | 30 |
| 4 | 7 | 2019-02-01 | Coat | 25 |
| 5 | 7 | 2019-03-10 | Shoes | 80 |
Find order details made by Jill and Eva. Consider the Jill and Eva as first names of customers. Output the order date, details and cost along with the first name. Order records based on the customer id in ascending order.
JOINs with Aggregation: The Trap Everyone Falls Into
JOINs work with GROUP BY, but there’s a classic bug that shows up constantly in code reviews:
Different people can share the same name. You’ll merge their data and get wrong numbers.
-- THE BUG: Two customers named 'John' get merged
SELECT
c.first_name,
COUNT(o.id) AS order_count
FROM customers c
INNER JOIN orders o
ON c.id = o.cust_id
GROUP BY c.first_name;
-- If you have 3 Johns, this counts all their orders together!
-- THE FIX: Always group by the unique key
SELECT
c.id,
c.first_name,
COUNT(o.id) AS order_count,
SUM(o.total_order_cost) AS total_spent
FROM customers c
INNER JOIN orders o
ON c.id = o.cust_id
GROUP BY c.id, c.first_name;When aggregating joined data, ask: “What am I counting?” COUNT(o.id) counts orders. COUNT(c.id) counts customers (but would give wrong results here since each customer appears multiple times with multiple orders).
Matching Similar Hosts and Guests
| host_id | nationality | gender | age |
|---|---|---|---|
| 0 | USA | M | 28 |
| 1 | USA | F | 29 |
| 2 | China | F | 31 |
| 3 | China | M | 24 |
| 4 | Mali | M | 30 |
| guest_id | nationality | gender | age |
|---|---|---|---|
| 0 | Mali | M | 21 |
| 1 | China | F | 23 |
| 2 | Mali | F | 27 |
| 3 | Australia | F | 24 |
| 4 | Luxembourg | M | 19 |
Find matching hosts and guests pairs in a way that they are both of the same gender and nationality. Output the host id and the guest id of matched pair.
Mistakes That Show Up All the Time
- Forgetting the
ONclause —INNER JOINrequiresON. You’ll get a syntax error, not a cross join. - Joining on the wrong columns — just because two columns have similar names doesn’t mean they should be joined. Look for foreign key → primary key connections.
- Being surprised when rows disappear —
INNER JOINonly returns matches. If you need to keep rows without a match, you wantLEFT JOIN(next lesson). - Grouping by non-unique fields — include the primary key (usually
id) in yourGROUP BY. Names and emails can have duplicates in messy real-world data.
Key Takeaways
INNER JOINreturns only rows that match in both tablesONspecifies the connection (usually foreign key = primary key)- Always use table aliases, and make them meaningful
- Qualify column names, even when not strictly required
- When aggregating,
GROUP BYunique keys (id), never just names
What’s Next
INNER JOIN works great when you only want matching rows. But what about the question: “Show me all customers, including those who haven’t ordered yet”? That’s where LEFT JOIN comes in, and it’s probably the most important join type for analytics work.