Medium JOINsAggregation Free

SQL interview question: LEFT JOIN vs INNER JOIN

Product Sales per Category, Unsold Included

The merchandising team wants a category report that also shows how much of the catalog never sold, so products with no orders must still be counted.

Return category, product_count (every product in the category), products_ordered (products that appear on at least one order line) and units_sold (the total quantity ordered, 0 if nothing sold). Order by category.

Skills: LEFT JOIN vs INNER JOIN, COUNT(DISTINCT) on the right table and COALESCE

Tables

products 20 rows
  • product_id integer PK
  • product_name text
  • category text
  • price real
  • rating real
product_idproduct_namecategorypricerating
1MacBook ProElectronics1999.994.8
2iPad AirElectronics599.994.8
3AirPods ProElectronics249.994.7
order_items 19 rows
  • id integer PK
  • order_id integer → orders
  • product_id integer → products
  • quantity integer
idorder_idproduct_idquantity
1131
2211
3261

How to solve it

An INNER JOIN keeps only rows that have a match in both tables, while a LEFT JOIN keeps every row from the left table and fills the right table's columns with NULL when there's no match. Use LEFT JOIN when rows without a match still need to be counted, and INNER JOIN when they should be dropped.

The difference on four rows

The Furniture category has four products. Join them to order_items with a LEFT JOIN:

SELECT p.product_name, oi.order_id
FROM products p
LEFT JOIN order_items oi ON oi.product_id = p.product_id
WHERE p.category = 'Furniture'
ORDER BY p.product_id;

You get four rows. Monitor Stand and Office Chair show their order ids (11 and 10), and Desk Lamp and Standing Desk show NULL because nobody ordered them. Change LEFT JOIN to INNER JOIN and only the two ordered products come back. That's the whole rule: an INNER JOIN drops left rows with no match, a LEFT JOIN keeps them.

Count the rows each join keeps

The store has 20 products and 19 order lines. Count the joined rows:

SELECT COUNT(*) FROM products p
JOIN order_items oi ON oi.product_id = p.product_id;

The INNER JOIN returns 19 rows, one per order line. The same query with LEFT JOIN returns 28: the 19 matches plus one NULL row for each of the 9 products that never sold. Notice that 28 is more than the 20 products you started with. A LEFT JOIN guarantees at least one row per left row, not exactly one, so Wireless Mouse shows up four times because it's on four order lines.

Why the INNER JOIN answer looks right

Run the practice question with an INNER JOIN and you still get three categories with plausible numbers:

SELECT p.category,
       COUNT(DISTINCT p.product_id) AS product_count,
       COUNT(DISTINCT oi.product_id) AS products_ordered
FROM products p
INNER JOIN order_items oi ON oi.product_id = p.product_id
GROUP BY p.category
ORDER BY p.category;

product_count comes back as 1, 8 and 2, the same as products_ordered. The real catalog has 5, 11 and 4. Nothing errors, and every category still appears because each one sold something. The bug only shows if you already know the catalog size, so it's easy to ship without noticing.

COUNT(*) after a LEFT JOIN

Once you switch to a LEFT JOIN, COUNT(*) counts the NULL rows too. Accessories gets 5 rows from the join but has only 1 order line, so COUNT(*) would report 5 sales. Count a column from the right table instead, such as COUNT(oi.id), which skips NULLs and returns 1. The same goes for SUM: it returns NULL for a group with no matches, so wrap it in COALESCE(..., 0).

The WHERE clause trap

A filter on the right table in WHERE quietly turns a LEFT JOIN back into an INNER JOIN. Say you want delivered orders per Electronics product:

SELECT p.product_name, COUNT(o.order_id) AS delivered_orders
FROM products p
LEFT JOIN order_items oi ON oi.product_id = p.product_id
LEFT JOIN orders o ON o.order_id = oi.order_id
WHERE p.category = 'Electronics' AND o.status = 'delivered'
GROUP BY p.product_id
ORDER BY p.product_id;

This returns 6 of the 11 Electronics products. The unsold ones have NULL status, and NULL = 'delivered' isn't true, so WHERE removes them. Headphones (only a pending order) and Keyboard Mechanical (only a shipped one) vanish too. Move the condition into the join:

SELECT p.product_name, COUNT(o.order_id) AS delivered_orders
FROM products p
LEFT JOIN order_items oi ON oi.product_id = p.product_id
LEFT JOIN orders o ON o.order_id = oi.order_id AND o.status = 'delivered'
WHERE p.category = 'Electronics'
GROUP BY p.product_id
ORDER BY p.product_id;

Now all 11 come back, five of them with 0. The filter on the left table (category) stays in WHERE. The filter on the right table (status) goes in ON.

What to say in the interview

Start with the business question: do rows without a match belong in the answer? If yes, LEFT JOIN from the table that must be complete. Then mention the follow-ups before you're asked: count a right-side column instead of *, COALESCE the sums, and keep right-table filters in ON. To list only the rows with no match, add WHERE oi.id IS NULL, the anti-join used in Customers With No Orders. For more practice, see the SQL JOIN interview questions and Join Four Tables.

Common questions

Is LEFT JOIN the same as LEFT OUTER JOIN?
Yes. OUTER is optional, so LEFT JOIN and LEFT OUTER JOIN return the same rows. A plain JOIN with no keyword is an INNER JOIN.
Can a LEFT JOIN return more rows than the left table has?
Yes. Every left row appears at least once, but a row with several matches appears once per match. Here 20 products become 28 rows because some products are on more than one order line.
How do I find rows that have no match?
LEFT JOIN, then keep the rows where a right-side key is NULL: WHERE oi.id IS NULL. On this data that lists the 9 products nobody ordered, such as Desk Lamp and USB-C Cable.
Is INNER JOIN faster than LEFT JOIN?
Sometimes, because an INNER JOIN gives the planner more freedom to reorder tables. Pick the join that returns the right rows first, then check the query plan if speed matters.
Why does my LEFT JOIN act like an INNER JOIN?
Usually a condition on the right table sits in WHERE. Unmatched rows have NULL there, the condition fails, and they're filtered out. Move that condition into the ON clause.

Practice on your iPhone

Query Dojo for iPhone has 85 interview questions, timed mock interviews and a tile builder, so you can write queries without a keyboard.

Get the app

Keep going