SQL LEFT JOIN and RIGHT JOIN to Include Unmatched Rows
In the previous guide, you learned that INNER JOIN returns only rows with matches in both tables. This is perfect when you want to see orders with their customers, or products with their categories. But what happens when you need the opposite perspective? What about customers who have never placed an order? Products with no reviews? Categories with no products assigned?
INNER JOIN silently drops these unmatched rows. It cannot show you what is missing, only what is present. To find gaps, absences, and unmatched records, you need outer joins.
LEFT JOIN keeps every row from the left table, even if there is no match in the right table. RIGHT JOIN does the same but for the right table. These operations are essential for finding missing data, generating complete reports, and answering "who has not" and "what does not" questions.
This guide covers both LEFT JOIN and RIGHT JOIN in depth: how they work, how they differ from INNER JOIN, why LEFT JOIN dominates real-world usage, and the powerful pattern of finding unmatched rows. Every example uses the ShopSmart sample database with full outputs (we defined it in a previous guide here, and then we extended it here).
The Problem: INNER JOIN Hides Missing Data
Let us revisit a query from the previous guide that counts orders per customer:
SELECT c.first_name || ' ' || c.last_name AS customer,
COUNT(o.id) AS order_count
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.first_name, c.last_name
ORDER BY order_count DESC;
Output:
| customer | order_count |
|---|---|
| Alice Johnson | 3 |
| Bob Martinez | 3 |
| Carol Singh | 2 |
| David Chen | 2 |
| Eva Brown | 1 |
| Frank Wilson | 1 |
Six customers appear. But the customers table has seven customers. Grace Taylor is completely absent because she has no orders. The INNER JOIN found no matching rows in the orders table for Grace, so it excluded her entirely.
For many reports, this is exactly what you want. But if you are building a customer management dashboard that shows all customers and their order activity, Grace's absence creates a blind spot. You need her to appear with an order count of 0.
This is the problem LEFT JOIN solves.
LEFT JOIN Explained
A LEFT JOIN (also written as LEFT OUTER JOIN) returns all rows from the left table, regardless of whether they have a match in the right table. For rows that do have matches, it works exactly like an INNER JOIN, combining columns from both tables. For rows with no match, it fills in NULL for every column from the right table.
SELECT columns
FROM left_table
LEFT JOIN right_table ON left_table.column = right_table.column;
The "left" table is the one in the FROM clause. The "right" table is the one after LEFT JOIN.
Your First LEFT JOIN
SELECT c.first_name || ' ' || c.last_name AS customer,
o.id AS order_id,
o.order_date,
o.total_amount
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
ORDER BY c.last_name, o.order_date;
Output:
| customer | order_id | order_date | total_amount |
|---|---|---|---|
| Eva Brown | 6 | 2024-03-20 | 34.99 |
| David Chen | 5 | 2024-03-12 | 155.00 |
| David Chen | 12 | 2024-04-25 | 45.00 |
| Alice Johnson | 1 | 2024-01-10 | 119.98 |
| Alice Johnson | 3 | 2024-02-20 | 77.49 |
| Alice Johnson | 9 | 2024-04-15 | 89.99 |
| Bob Martinez | 2 | 2024-01-15 | 89.99 |
| Bob Martinez | 7 | 2024-04-01 | 63.00 |
| Bob Martinez | 11 | 2024-04-22 | 174.99 |
| Carol Singh | 4 | 2024-03-05 | 129.99 |
| Carol Singh | 10 | 2024-04-18 | 63.99 |
| Grace Taylor | NULL | NULL | NULL |
| Frank Wilson | 8 | 2024-04-10 | 199.98 |
Grace Taylor now appears in the results. She has no matching orders, so order_id, order_date, and total_amount are all NULL. Every other customer appears with their actual order data, just like an INNER JOIN.
Visualizing LEFT JOIN
┌───────────────┐ ┌───────────────┐
│ │ │ │
│ customers │ │ orders │
│ ┌──┼─────┼──┐ │
│ Grace │ │ │ │ │
│ (no │ matched│ │ │
│ orders) │ rows │ │ │
│ │ │ │ │
│ INCLUDED │ INCLUDED │ excluded │
│ └──┼─────┼──┘ │
│ │ │ │
└───────────────┘ └───────────────┘
◄────── LEFT JOIN returns this area ──────►
- Left circle entirely (all customers): Every customer appears, with or without orders
- Intersection (customers with orders): Matched data from both tables
- Right circle only (orders without matching customers): Not applicable with foreign key constraints, but would be excluded
The key difference from INNER JOIN: the left circle only area (customers without orders) is now included instead of discarded.
LEFT JOIN with COUNT: The Complete Picture
Now let us fix the original counting query:
SELECT c.first_name || ' ' || c.last_name AS customer, COUNT(o.id) AS order_count
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.first_name, c.last_name
ORDER BY order_count DESC;
Output:
| customer | order_count |
|---|---|
| Alice Johnson | 3 |
| Bob Martinez | 3 |
| Carol Singh | 2 |
| David Chen | 2 |
| Eva Brown | 1 |
| Frank Wilson | 1 |
| Grace Taylor | 0 |
Grace now appears with an order count of 0. All seven customers are accounted for.
This distinction is critical when using LEFT JOIN with aggregation:
-- Wrong: COUNT(*) counts the row itself, which exists for Grace (it is just filled with NULLs)
SELECT c.first_name, COUNT(*) AS order_count
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.first_name;
-- Grace shows order_count = 1 (wrong!)
-- Correct: COUNT(o.id) counts non-NULL values in the orders table
SELECT c.first_name, COUNT(o.id) AS order_count
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.first_name;
-- Grace shows order_count = 0 (correct!)
When using LEFT JOIN with COUNT, always count a column from the right table (like o.id), not *. COUNT(*) counts every row including those filled with NULLs, while COUNT(o.id) correctly counts only actual matches.
NULL Values in LEFT JOIN Results
Understanding how NULLs appear in LEFT JOIN results is essential for writing correct queries.
When a row from the left table has no match, every column from the right table is filled with NULL:
SELECT c.first_name,
c.last_name,
o.id AS order_id,
o.order_date,
o.total_amount,
o.status
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE c.first_name = 'Grace';
Output:
| first_name | last_name | order_id | order_date | total_amount | status |
|---|---|---|---|---|---|
| Grace | Taylor | NULL | NULL | NULL | NULL |
Grace's own columns (first_name, last_name) have their real values. But every column from the orders table is NULL because no matching order exists.
This NULL behavior becomes the foundation for one of the most important SQL patterns: finding unmatched rows.
Finding Unmatched Rows: The IS NULL Pattern
One of the most powerful uses of LEFT JOIN is finding rows in the left table that have no corresponding match in the right table. The pattern combines LEFT JOIN with a WHERE check for NULL on the right table's primary key.
Customers Who Have Never Ordered
SELECT c.first_name || ' ' || c.last_name AS customer,
c.email,
c.signup_date
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.id IS NULL;
Output:
| customer | signup_date | |
|---|---|---|
| Grace Taylor | grace@email.com | 2024-04-01 |
The logic works like this:
LEFT JOINincludes all customers, even those with no orders- For customers without orders,
o.idisNULL WHERE o.id IS NULLfilters to only those unmatched customers
This pattern is invaluable for business questions like:
- Which customers should we send a first-purchase promotion?
- Which products have never been reviewed?
- Which categories have no products?
Products That Have Never Been Ordered
SELECT p.name AS product,
p.price,
p.stock_quantity
FROM products p
LEFT JOIN order_items oi ON p.id = oi.product_id
WHERE oi.id IS NULL;
Output:
| product | price | stock_quantity |
|---|---|---|
| Data Science Handbook | 42.50 | 30 |
| Bluetooth Speaker | 65.00 | 0 |
Two products sit in the catalog but have never appeared in any order. This insight could trigger a marketing push or a pricing review.
Products With No Reviews
SELECT p.name AS product,
p.price,
cat.name AS category
FROM products p
INNER JOIN categories cat ON p.category_id = cat.id
LEFT JOIN reviews r ON p.id = r.product_id
WHERE r.id IS NULL;
Output:
| product | price | category |
|---|---|---|
| Bluetooth Speaker | 65.00 | Electronics |
Only the Bluetooth Speaker has no reviews. Notice how this query uses both INNER JOIN (to get category names, since every product has a category) and LEFT JOIN (to check for reviews, which might not exist).
Categories with No Products
-- First, let's add an empty category for demonstration
INSERT INTO categories VALUES (5, 'Toys', 'Toys and games for all ages');
SELECT cat.name AS category,
cat.description
FROM categories cat
LEFT JOIN products p ON cat.id = p.category_id
WHERE p.id IS NULL;
Output:
| category | description |
|---|---|
| Toys | Toys and games for all ages |
The new "Toys" category exists but has no products assigned to it.
-- Template for finding unmatched rows:
SELECT left_table.columns
FROM left_table
LEFT JOIN right_table ON left_table.key = right_table.foreign_key
WHERE right_table.primary_key IS NULL;
This pattern answers any "which X has no Y" question:
- Customers with no orders
- Products with no reviews
- Categories with no products
- Employees with no assigned projects
- Students not enrolled in any course
LEFT JOIN with Aggregation
LEFT JOIN combined with aggregate functions produces complete summaries that include entities with zero counts or zero totals.
Complete Customer Spending Report
SELECT c.first_name || ' ' || c.last_name AS customer,
c.city,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.total_amount), 0) AS total_spent,
COALESCE(ROUND(AVG(o.total_amount), 2), 0) AS avg_order_value
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.first_name, c.last_name, c.city
ORDER BY total_spent DESC;
Output:
| customer | city | order_count | total_spent | avg_order_value |
|---|---|---|---|---|
| Bob Martinez | Los Angeles | 3 | 327.98 | 109.33 |
| Alice Johnson | New York | 3 | 287.46 | 95.82 |
| David Chen | New York | 2 | 200 | 100 |
| Frank Wilson | Chicago | 1 | 199.98 | 199.98 |
| Carol Singh | Chicago | 2 | 193.98000000000002 | 96.99 |
| Eva Brown | Seattle | 1 | 34.99 | 34.99 |
| Grace Taylor | null | 0 | 0 | 0 |
COALESCE is essential here. Without it, Grace's total_spent and avg_order_value would be NULL instead of 0:
-- Without COALESCE: Grace gets NULLs
SELECT c.first_name,
SUM(o.total_amount) AS total_spent -- NULL for Grace
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.first_name;
-- With COALESCE: Grace gets 0
SELECT c.first_name,
COALESCE(SUM(o.total_amount), 0) AS total_spent -- 0 for Grace
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.first_name;
Complete Category Report
SELECT cat.name AS category,
COUNT(p.id) AS product_count,
COALESCE(ROUND(AVG(p.price), 2), 0) AS avg_price,
COALESCE(SUM(p.stock_quantity), 0) AS total_stock
FROM categories cat
LEFT JOIN products p ON cat.id = p.category_id
GROUP BY cat.id, cat.name
ORDER BY product_count DESC;
Output:
| category | product_count | avg_price | total_stock |
|---|---|---|---|
| Electronics | 4 | 57.49 | 425 |
| Sports | 3 | 57.66 | 340 |
| Books | 2 | 38.75 | 80 |
| Home & Kitchen | 1 | 129.99 | 25 |
| Toys | 0 | 0 | 0 |
The Toys category appears with zeros. An INNER JOIN would have hidden it completely.