Skip to main content

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:

customerorder_count
Alice Johnson3
Bob Martinez3
Carol Singh2
David Chen2
Eva Brown1
Frank Wilson1

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:

customerorder_idorder_datetotal_amount
Eva Brown62024-03-2034.99
David Chen52024-03-12155.00
David Chen122024-04-2545.00
Alice Johnson12024-01-10119.98
Alice Johnson32024-02-2077.49
Alice Johnson92024-04-1589.99
Bob Martinez22024-01-1589.99
Bob Martinez72024-04-0163.00
Bob Martinez112024-04-22174.99
Carol Singh42024-03-05129.99
Carol Singh102024-04-1863.99
Grace TaylorNULLNULLNULL
Frank Wilson82024-04-10199.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:

customerorder_count
Alice Johnson3
Bob Martinez3
Carol Singh2
David Chen2
Eva Brown1
Frank Wilson1
Grace Taylor0

Grace now appears with an order count of 0. All seven customers are accounted for.

COUNT(*) vs COUNT(column) with LEFT JOIN

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_namelast_nameorder_idorder_datetotal_amountstatus
GraceTaylorNULLNULLNULLNULL

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:

customeremailsignup_date
Grace Taylorgrace@email.com2024-04-01

The logic works like this:

  1. LEFT JOIN includes all customers, even those with no orders
  2. For customers without orders, o.id is NULL
  3. WHERE o.id IS NULL filters 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:

productpricestock_quantity
Data Science Handbook42.5030
Bluetooth Speaker65.000

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:

productpricecategory
Bluetooth Speaker65.00Electronics

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:

categorydescription
ToysToys and games for all ages

The new "Toys" category exists but has no products assigned to it.

The IS NULL Pattern Summary
-- 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:

customercityorder_counttotal_spentavg_order_value
Bob MartinezLos Angeles3327.98109.33
Alice JohnsonNew York3287.4695.82
David ChenNew York2200100
Frank WilsonChicago1199.98199.98
Carol SinghChicago2193.9800000000000296.99
Eva BrownSeattle134.9934.99
Grace Taylornull000

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:

categoryproduct_countavg_pricetotal_stock
Electronics457.49425
Sports357.66340
Books238.7580
Home & Kitchen1129.9925
Toys000

The Toys category appears with zeros. An INNER JOIN would have hidden it completely.

Product Review Summary

SELECT p.name AS product,
p.price,
COUNT(r.id) AS review_count,
COALESCE(ROUND(AVG(r.rating), 2), 0) AS avg_rating,
CASE
WHEN COUNT(r.id) = 0 THEN 'No Reviews'
WHEN AVG(r.rating) >= 4.5 THEN 'Excellent'
WHEN AVG(r.rating) >= 4.0 THEN 'Good'
WHEN AVG(r.rating) >= 3.0 THEN 'Average'
ELSE 'Poor'
END AS rating_label
FROM products p
LEFT JOIN reviews r ON p.id = r.product_id
GROUP BY p.id, p.name, p.price
ORDER BY avg_rating DESC, review_count DESC;

Output:

productpricereview_countavg_ratingrating_label
Wireless Mouse29.9934.67Excellent
Running Shoes X1110.0034.67Excellent
USB-C Hub45.0024.50Excellent
SQL for Beginners34.9924.50Excellent
Coffee Maker Pro129.9924.50Excellent
Yoga Mat Premium38.0024.50Excellent
Stainless Water Bottle24.9934.33Good
Mechanical Keyboard89.9934.00Good
Data Science Handbook42.5013.00Average
Bluetooth Speaker65.0000No Reviews

Every product appears, including the Bluetooth Speaker with zero reviews.

RIGHT JOIN Explained

A RIGHT JOIN (also written as RIGHT OUTER JOIN) is the mirror image of LEFT JOIN. It returns all rows from the right table, regardless of whether they have matches in the left table. Unmatched rows from the right table get NULL values for the left table's columns.

SELECT columns
FROM left_table
RIGHT JOIN right_table ON left_table.column = right_table.column;

RIGHT JOIN Example

SELECT o.id AS order_id,
o.order_date,
c.first_name || ' ' || c.last_name AS customer
FROM orders o
RIGHT JOIN customers c ON o.customer_id = c.id
ORDER BY c.last_name;

Output:

order_idorder_datecustomer
62024-03-20Eva Brown
52024-03-12David Chen
122024-04-25David Chen
12024-01-10Alice Johnson
32024-02-20Alice Johnson
92024-04-15Alice Johnson
22024-01-15Bob Martinez
72024-04-01Bob Martinez
112024-04-22Bob Martinez
42024-03-05Carol Singh
102024-04-18Carol Singh
NULLNULLGrace Taylor
82024-04-10Frank Wilson

Grace appears with NULL order data. The right table (customers) is fully preserved.

Visualizing RIGHT JOIN

    ┌───────────────┐     ┌───────────────┐
│ │ │ │
│ orders │ │ customers │
│ ┌──┼─────┼──┐ │
│ │ │ │ │ Grace │
│ excluded │ matched│ │ (no │
│ │ rows │ │ orders) │
│ │ │ │ │
│ │ INCLUDED │ INCLUDED │
│ └──┼─────┼──┘ │
│ │ │ │
└───────────────┘ └───────────────┘

◄──── RIGHT JOIN returns this area ────►

Why LEFT JOIN Is More Common Than RIGHT JOIN

In practice, you will see LEFT JOIN far more often than RIGHT JOIN. Here is why:

Any RIGHT JOIN Can Be Rewritten as a LEFT JOIN

Every RIGHT JOIN query can be rewritten as a LEFT JOIN by simply swapping the table order:

-- RIGHT JOIN: Keep all customers
SELECT o.id, c.first_name
FROM orders o
RIGHT JOIN customers c ON o.customer_id = c.id;

-- Equivalent LEFT JOIN: Same result, just swap the tables
SELECT o.id, c.first_name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id;

Both queries produce identical results. The LEFT JOIN version is more natural to read: "Start with all customers, then look for their orders."

Reading Direction

Most people read SQL from top to bottom and left to right. LEFT JOIN keeps the "primary" table (the one you want all rows from) at the start of the query, which matches the natural reading flow:

-- Natural: "Start with customers, find their orders"
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id

-- Less natural: "Start with orders, but actually keep all customers"
FROM orders o
RIGHT JOIN customers c ON o.customer_id = c.id

Industry Convention

The SQL community has converged on using LEFT JOIN almost exclusively. Using RIGHT JOIN is not wrong, but it makes your code less familiar to other developers. Some style guides explicitly discourage RIGHT JOIN.

Practical Advice

Always use LEFT JOIN. If you find yourself reaching for RIGHT JOIN, swap the table order and use LEFT JOIN instead. Your queries will be more consistent and more readable.

-- Instead of RIGHT JOIN:
FROM table_a RIGHT JOIN table_b ON ...

-- Use LEFT JOIN with swapped tables:
FROM table_b LEFT JOIN table_a ON ...

LEFT JOIN vs INNER JOIN: Side by Side

Let us see the exact difference between the two join types with the same tables:

INNER JOIN Result

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;
customerorder_count
Alice Johnson3
Bob Martinez3
David Chen2
Carol Singh2
Frank Wilson1
Eva Brown1

6 rows. Grace is missing.

LEFT JOIN Result

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;
customerorder_count
Alice Johnson3
Bob Martinez3
David Chen2
Carol Singh2
Frank Wilson1
Eva Brown1
Grace Taylor0

7 rows. Grace appears with 0 orders.

When to Use Which

ScenarioUseWhy
Show only customers who have placed ordersINNER JOINYou only want matched data
Show ALL customers, including those without ordersLEFT JOINYou need the complete picture
Find customers who have NEVER orderedLEFT JOIN + WHERE IS NULLOnly outer joins can reveal missing data
Join products with categories (every product has one)INNER JOINNo unmatched rows expected
Show products with optional review dataLEFT JOINNot every product has reviews
Rule of Thumb

If the relationship is mandatory (every order has a customer, every product has a category), INNER JOIN is fine. If the relationship is optional (not every customer has orders, not every product has reviews), use LEFT JOIN to preserve all rows from the primary table.

Multiple LEFT JOINs

You can chain multiple LEFT JOIN operations to preserve rows through several optional relationships:

Customer Dashboard with Orders and Reviews

SELECT c.first_name || ' ' || c.last_name AS customer,
COUNT(DISTINCT o.id) AS order_count,
COALESCE(SUM(o.total_amount), 0) AS total_spent,
COUNT(DISTINCT r.id) AS review_count
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
LEFT JOIN reviews r ON c.id = r.customer_id
GROUP BY c.id, c.first_name, c.last_name
ORDER BY total_spent DESC;

Output:

customerorder_counttotal_spentreview_count
Alice Johnson31437.35
Bob Martinez31311.924
Carol Singh2775.92000000000014
David Chen26003
Frank Wilson1399.962
Eva Brown1104.973
Grace Taylor000

Grace appears with zeros across all metrics. Both LEFT JOIN operations preserve her row.

Use COUNT(DISTINCT) When Chaining LEFT JOINs

When you LEFT JOIN to multiple tables, rows can multiply due to the cross-product effect. Using COUNT(DISTINCT o.id) instead of COUNT(o.id) prevents inflated counts.

-- Problem: Without DISTINCT, order counts get inflated
-- If Alice has 3 orders and 5 reviews, each order gets repeated 5 times
-- COUNT(o.id) would return 15 instead of 3

-- Solution: COUNT(DISTINCT o.id) counts unique orders only
COUNT(DISTINCT o.id) AS order_count

Complete Product Overview

SELECT p.name AS product,
cat.name AS category,
p.price,
p.stock_quantity AS stock,
COUNT(DISTINCT oi.id) AS times_ordered,
COALESCE(SUM(oi.quantity), 0) AS units_sold,
COUNT(DISTINCT r.id) AS review_count,
COALESCE(ROUND(AVG(r.rating), 1), 0) AS avg_rating
FROM products p
INNER JOIN categories cat ON p.category_id = cat.id
LEFT JOIN order_items oi ON p.id = oi.product_id
LEFT JOIN reviews r ON p.id = r.product_id
GROUP BY p.id, p.name, cat.name, p.price, p.stock_quantity
ORDER BY units_sold DESC, avg_rating DESC;

Output:

productcategorypricestocktimes_orderedunits_soldreview_countavg_rating
Mechanical KeyboardElectronics89.997541234
Running Shoes X1Sports110603934.7
Stainless Water BottleSports24.991803934.3
Wireless MouseElectronics29.991501634.7
USB-C HubElectronics452003624.5
SQL for BeginnersBooks34.99502424.5
Yoga Mat PremiumSports381002424.5
Coffee Maker ProHome & Kitchen129.99251224.5
Data Science HandbookBooks42.5300013
Bluetooth SpeakerElectronics6500000

Notice the mix of join types: INNER JOIN for categories (every product has one) and LEFT JOIN for order items and reviews (which might not exist).

WHERE Clause Placement with LEFT JOIN

The placement of WHERE conditions in a LEFT JOIN query has a critical impact on results. Getting this wrong is one of the most common LEFT JOIN mistakes.

The Trap: WHERE Turns LEFT JOIN into INNER JOIN

-- Intention: Show all customers, but only their completed orders
-- WRONG: This filters out Grace (and anyone without completed orders)
SELECT c.first_name || ' ' || c.last_name AS customer,
o.order_date,
o.status
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.status = 'completed';

Output:

customerorder_datestatus
Alice Johnson2024-01-10completed
Bob Martinez2024-01-15completed
Alice Johnson2024-02-20completed
Alice Johnson2024-04-15completed
Carol Singh2024-04-18completed

Grace is gone. So are David, Eva, and Frank (they have no completed orders). The WHERE o.status = 'completed' condition eliminates any row where o.status is NULL, which includes all the unmatched LEFT JOIN rows. The LEFT JOIN effectively becomes an INNER JOIN.

The Fix: Move the Condition to the ON Clause

To filter the right table without eliminating unmatched left rows, place the condition in the ON clause:

-- CORRECT: Show all customers, with completed orders if they have any
SELECT c.first_name || ' ' || c.last_name AS customer,
o.order_date,
o.status
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id AND o.status = 'completed'
ORDER BY customer;

Output:

customerorder_datestatus
Alice Johnson2024-01-10completed
Alice Johnson2024-02-20completed
Alice Johnson2024-04-15completed
Bob Martinez2024-01-15completed
Carol Singh2024-04-18completed
David ChenNULLNULL
Eva BrownNULLNULL
Frank WilsonNULLNULL
Grace TaylorNULLNULL

Now all customers appear. Those with completed orders show the details. Those without completed orders (including Grace who has no orders at all, and David/Eva/Frank who have orders but none completed) show NULL.

Aggregated Version

SELECT c.first_name || ' ' || c.last_name AS customer,
COUNT(o.id) AS completed_orders,
COALESCE(SUM(o.total_amount), 0) AS completed_revenue
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id AND o.status = 'completed'
GROUP BY c.id, c.first_name, c.last_name
ORDER BY completed_revenue DESC;

Output:

customercompleted_orderscompleted_revenue
Alice Johnson3287.46
Bob Martinez189.99
Carol Singh163.99
David Chen00
Eva Brown00
Frank Wilson00
Grace Taylor00
The Critical Rule

When using LEFT JOIN, any filter on the right table must go in the ON clause, not the WHERE clause. Putting it in WHERE converts the LEFT JOIN into an INNER JOIN by eliminating rows with NULLs.

-- WHERE on right table: Destroys the LEFT JOIN effect
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.status = 'completed' -- NULLs filtered out, Grace disappears

-- ON clause condition: Preserves the LEFT JOIN effect
LEFT JOIN orders o ON c.id = o.customer_id AND o.status = 'completed'
-- Grace kept with NULLs, only completed orders matched

Filters on the left table are fine in WHERE because left table rows are always preserved:

-- This is fine: Filtering the LEFT table in WHERE
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE c.city = 'New York' -- Only New York customers, but still LEFT JOINed

Real-World LEFT JOIN Patterns

Customer Engagement Report

SELECT c.first_name || ' ' || c.last_name AS customer,
c.signup_date,
COUNT(DISTINCT o.id) AS orders,
COALESCE(SUM(o.total_amount), 0) AS revenue,
COUNT(DISTINCT r.id) AS reviews,
CASE
WHEN COUNT(o.id) = 0 AND COUNT(r.id) = 0 THEN 'Inactive'
WHEN COUNT(o.id) = 0 THEN 'Reviewer Only'
WHEN COUNT(o.id) >= 3 THEN 'Highly Active'
WHEN COUNT(o.id) >= 2 THEN 'Active'
ELSE 'New Buyer'
END AS engagement_level
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
LEFT JOIN reviews r ON c.id = r.customer_id
GROUP BY c.id, c.first_name, c.last_name, c.signup_date
ORDER BY revenue DESC;

Output:

customersignup_dateordersrvenuereviewsengagement_level
Alice Johnson2023-01-1531437.35Highly Active
Bob Martinez2023-03-2231311.924Highly Active
Carol Singh2023-06-102775.92000000000014Highly Active
David Chen2023-08-0526003Highly Active
Frank Wilson2024-02-281399.962Active
Eva Brown2024-01-181104.973Highly Active
Grace Taylor2024-04-01000Inactive

Inventory Gap Analysis

SELECT cat.name AS category,
COUNT(p.id) AS product_count,
COUNT(CASE WHEN p.stock_quantity = 0 THEN 1 END) AS out_of_stock,
COUNT(CASE WHEN p.stock_quantity > 0 AND p.stock_quantity < 30 THEN 1 END) AS low_stock,
COALESCE(SUM(p.stock_quantity), 0) AS total_units
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:

categoryproduct_countout_of_stocklow_stocktotal_units
Electronics410425
Sports300340
Books20080
Home & Kitchen10125
Toys0000

Every category is visible, including Toys with zero products. This is exactly the kind of comprehensive report that management needs.

Finding Gaps in Data

-- Products that exist but have NEITHER orders NOR 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 order_items oi ON p.id = oi.product_id
LEFT JOIN reviews r ON p.id = r.product_id
WHERE oi.id IS NULL
AND r.id IS NULL;

Output:

productpricecategory
Bluetooth Speaker65.00Electronics

The Bluetooth Speaker has never been ordered and never been reviewed. This is a strong signal that the product needs attention.

FULL OUTER JOIN: A Brief Mention

For completeness, SQL also offers FULL OUTER JOIN (or FULL JOIN), which returns all rows from both tables, filling in NULLs on whichever side has no match:

-- All customers and all orders, matched where possible
SELECT c.first_name, o.id AS order_id
FROM customers c
FULL OUTER JOIN orders o ON c.id = o.customer_id;

This returns customers without orders (NULL on the order side) AND orders without customers (NULL on the customer side, though this cannot happen with our foreign key constraint).

FULL OUTER JOIN is rarely needed in practice because most schemas enforce referential integrity with foreign keys. It is most useful for data reconciliation tasks where you are comparing two datasets that might have mismatches on either side.

Database Support

FULL OUTER JOIN is supported in PostgreSQL, SQL Server, and Oracle. MySQL does not support FULL OUTER JOIN natively. In MySQL, you can simulate it by combining a LEFT JOIN and a RIGHT JOIN with UNION.

Practical Exercises

Exercise 1

Show all products with their review count and average rating. Products with no reviews should show 0 for count and rating.

SELECT p.name AS product,
p.price,
COUNT(r.id) AS review_count,
COALESCE(ROUND(AVG(r.rating), 2), 0) AS avg_rating
FROM products p
LEFT JOIN reviews r ON p.id = r.product_id
GROUP BY p.id, p.name, p.price
ORDER BY avg_rating DESC, review_count DESC;

Expected output:

productpricereview_countavg_rating
Wireless Mouse29.9934.67
Running Shoes X111034.67
USB-C Hub4524.5
SQL for Beginners34.9924.5
Coffee Maker Pro129.9924.5
Yoga Mat Premium3824.5
Stainless Water Bottle24.9934.33
Mechanical Keyboard89.9934
Data Science Handbook42.513
Bluetooth Speaker6500

Exercise 2

Find all categories and the number of available products in each. Include categories with no products.

SELECT cat.name AS category,
COUNT(p.id) AS available_products
FROM categories cat
LEFT JOIN products p ON cat.id = p.category_id AND p.is_available = true
GROUP BY cat.id, cat.name
ORDER BY available_products DESC;

Expected output:

categoryavailable_products
Electronics3
Sports3
Books2
Home & Kitchen1
Toys0

Exercise 3

Find customers who have placed orders but never written a review.

SELECT DISTINCT c.first_name || ' ' || c.last_name AS customer,
c.email
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
LEFT JOIN reviews r ON c.id = r.customer_id
WHERE r.id IS NULL;

Expected output (depends on data, but the pattern finds the gap).

Exercise 4

Show all customers with their total spending on completed orders only. Customers with no completed orders should show 0.

SELECT c.first_name || ' ' || c.last_name AS customer,
COUNT(o.id) AS completed_orders,
COALESCE(SUM(o.total_amount), 0) AS completed_spending
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id AND o.status = 'completed'
GROUP BY c.id, c.first_name, c.last_name
ORDER BY completed_spending DESC;

Expected output:

customercompleted_orderscompleted_spending
Alice Johnson3287.46
Bob Martinez189.99
Carol Singh163.99
David Chen00
Eva Brown00
Frank Wilson00
Grace Taylor00

Exercise 5

Create a report showing every category with its product count, total revenue from orders, and average product rating. Categories with no products, no orders, or no reviews should show 0.

SELECT cat.name AS category,
COUNT(DISTINCT p.id) AS products,
COALESCE(ROUND(SUM(DISTINCT oi.quantity * oi.unit_price), 2), 0) AS revenue,
COALESCE(ROUND(AVG(r.rating), 2), 0) AS avg_rating
FROM categories cat
LEFT JOIN products p ON cat.id = p.category_id
LEFT JOIN order_items oi ON p.id = oi.product_id
LEFT JOIN reviews r ON p.id = r.product_id
GROUP BY cat.id, cat.name
ORDER BY revenue DESC;
categoryproductsrevenueavg_rating
Electronics4194.974.24
Sports3172.994.5
Home & Kitchen1129.994.5
Books234.994.2
Toys000

Key Takeaways

LEFT JOIN is one of the most important tools in SQL, enabling you to find missing data, build complete reports, and answer "who has not" questions. Here is what you should remember:

  • LEFT JOIN returns all rows from the left table, filling in NULL for unmatched right table columns
  • RIGHT JOIN does the same but for the right table. In practice, always use LEFT JOIN and swap the table order if needed
  • INNER JOIN excludes unmatched rows from both sides. LEFT JOIN preserves unmatched rows from the left
  • The IS NULL pattern (LEFT JOIN + WHERE right_table.pk IS NULL) is the standard way to find missing or unmatched data
  • When using LEFT JOIN with COUNT, always count a column from the right table (COUNT(o.id)), never COUNT(*)
  • Use COALESCE to replace NULL aggregate results with meaningful defaults like 0
  • Filters on the right table must go in the ON clause, not WHERE. Placing them in WHERE converts the LEFT JOIN into an INNER JOIN
  • Filters on the left table can safely go in WHERE
  • Use COUNT(DISTINCT ...) when chaining multiple LEFT JOIN operations to avoid inflated counts from row multiplication
  • Use INNER JOIN when the relationship is mandatory. Use LEFT JOIN when the relationship is optional
  • LEFT JOIN is essential for complete reports that must show all entities, including those with zero activity

Mastering LEFT JOIN gives you the ability to see both what exists and what is missing in your data, a capability that is essential for thorough analysis and accurate reporting.