LEFT and RIGHT OUTER JOINs
Progress Tracking
Log in to save this lesson and continue from where you left off.
The Join You’ll Use Most Often
INNER JOIN is great when you only want matches. But most analytics questions are different:
- “Show me all customers, even if they haven’t ordered.”
- “List every product, including ones never sold.”
- “Find users who haven’t logged in.”
That’s LEFT JOIN territory. In practice, LEFT JOIN appears in maybe 70% of production analytics queries. Master this one.
How LEFT JOIN Works
SELECT
e.first_name,
d.dept_name
FROM employees e
LEFT JOIN departments d
ON e.dept_id = d.id;| 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 |
Left join customers with orders so every customer appears, even those with no orders.
The Anti-Join Pattern: Finding What’s Missing
This is one of the most common SQL interview patterns for data analyst roles. You’ll see it constantly:
- “Find customers who have never placed an order.”
- “Which products have never been sold?”
- “List employees with no direct reports.”
The trick: LEFT JOIN, then filter for NULL on the right side.
Add a WHERE clause to keep only customers with no matching orders. Check for NULL on the right table’s primary key.
After the LEFT JOIN, customers without orders have NULL in all the order columns. We use that to filter down to just the unmatched rows.
Always check IS NULL on the right table’s primary key (like o.id). It’s guaranteed to be non-NULL for real matches, so NULL definitively means “no match found.”
Practice: Finding Posts by Reaction Type
Use a JOIN to connect posts with their reactions, then filter for the right reaction type.
| post_id | poster | post_text | post_keywords | post_date |
|---|---|---|---|---|
| 0 | 2 | The Lakers game from last night was great. | [basketball,lakers,nba] | 2019-01-01 |
| 1 | 1 | Lebron James is top class. | [basketball,lebron_james,nba] | 2019-01-02 |
| 2 | 2 | Asparagus tastes OK. | [asparagus,food] | 2019-01-01 |
| 3 | 1 | Spaghetti is an Italian food. | [spaghetti,food] | 2019-01-02 |
| 4 | 3 | User 3 is not sharing interests | [#spam#] | 2019-01-01 |
| poster | friend | reaction | date_day | post_id |
|---|---|---|---|---|
| 2 | 1 | like | 1 | 0 |
| 2 | 6 | like | 1 | 0 |
| 1 | 2 | like | 1 | 1 |
| 1 | 3 | heart | 1 | 1 |
| 1 | 4 | like | 1 | 1 |
Find all posts which were reacted to with a heart. For such posts output all columns from facebook_posts table.
Practice: Filtering Host Units
Join the host and unit tables, then filter and aggregate.
| 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 |
| host_id | unit_id | unit_type | n_beds | n_bedrooms | country | city |
|---|---|---|---|---|---|---|
| 0 | A1 | Room | 1 | 1 | USA | New York |
| 0 | A2 | Room | 1 | 1 | USA | New Jersey |
| 0 | A3 | Room | 1 | 1 | USA | New Jersey |
| 1 | A4 | Apartment | 2 | 1 | USA | Houston |
| 1 | A5 | Apartment | 2 | 1 | USA | Las Vegas |
Write a query that returns how many different apartment-type units (counted by distinct `unit_id`) are owned by people under 30, grouped by their nationality. Sort the results by the number of apartments in descending order.
The Filter Trap: A Bug That Bites Everyone
This is one of the most common SQL bugs in production. It shows up in dashboards at multiple companies. Here’s how it happens:
Putting a WHERE filter on the right table accidentally turns your LEFT JOIN into an INNER JOIN.
Your team wants to see every customer and flag who has bought a Coat — so they can target the rest with a promotion. You need all customers in the report, with Coat order details attached where they exist.
-- THE BUG: This drops customers with no orders!
SELECT
c.id,
c.first_name,
o.order_details
FROM customers c
LEFT JOIN orders o
ON c.id = o.cust_id
WHERE o.order_details = 'Coat';What went wrong? Customers without orders have NULL for o.order_details. And NULL = 'Coat' is not TRUE (it’s NULL), so those rows get filtered out. Your LEFT JOIN just became an INNER JOIN. The customers you wanted to target with the promo have disappeared from the report.
The Fix: Move the Filter to ON
The ON clause already contains the join condition (c.id = o.cust_id). We’re not replacing it — we’re adding the filter with AND to narrow which rows match.
| 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 |
| 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 |
The query below only shows customers who bought Coats — everyone else vanishes. Move the `order_details` filter from `WHERE` into the `ON` clause so all customers appear.
Now the join only matches Coat orders, but customers who never bought a Coat still appear (with NULLs). That’s what you need — the full roster shows who to target with the promo.
ON = how rows match
WHERE = which rows survive
RIGHT JOIN: The One Nobody Uses
RIGHT JOIN is the mirror image of LEFT JOIN. It keeps all rows from the right table.
In practice, almost nobody uses RIGHT JOIN. It’s harder to read (the “main” table is on the right, which feels backwards), and it can always be rewritten as a LEFT JOIN by swapping the table order:
-- These are equivalent:
-- RIGHT JOIN version:
SELECT
c.first_name,
o.id AS order_id
FROM customers c
RIGHT JOIN orders o
ON c.id = o.cust_id;
-- LEFT JOIN version (preferred):
SELECT
c.first_name,
o.id AS order_id
FROM orders o
LEFT JOIN customers c
ON c.id = o.cust_id;Most teams standardize on LEFT JOIN. It’s easier to reason about when the “preserved” table is always on the left.
Key Takeaways
LEFT JOINkeeps all rows from the left table, fillingNULLs for unmatched right columnsWHEREon right-table columns can accidentally breakLEFT JOINbehavior — move filters toON- Skip
RIGHT JOIN; rewrite asLEFT JOINwith swapped tables
What’s Next
What if you need unmatched rows from both sides? “Show me customers without orders and orders without valid customers.” That’s FULL OUTER JOIN territory.