Skip to content
SQLSimplified

INNER JOIN vs LEFT JOIN in SQL, With Real Output

See exactly which rows each join keeps, why switching between them sometimes changes nothing, and the row-count bug neither tutorial warns you about.

JoinsBeginner SQLDebugging

You switched INNER to LEFT and the row count barely moved

Here's the confusing part nobody mentions: swap INNER JOIN for LEFT JOIN in a real query, and sometimes the result doesn't grow the way the tutorials promise. Sometimes it grows by a strange, specific number. The textbook answer, "LEFT JOIN keeps unmatched rows," is true, but it only tells you half of what's happening.

The full rule: a join produces one output row for every matching pair. INNER JOIN keeps only pairs that match on both sides. LEFT JOIN keeps those same matched pairs, unchanged, and adds one extra row, with NULL on the right, for every left-table row that matched nothing at all. If a left row matches three times, both joins show it three times. If a left row matches zero times, only LEFT JOIN shows it, once. That second case is the only thing that ever differs between them, and it's also the reason a single matched row quietly duplicating itself causes more production bugs than the missing rows ever do.

INNER JOIN: only the pairs that match

The store dataset has 15 customers. Some have one completed order, some have several, and a few have none at all, every order they placed was cancelled or is still pending.

Loading playground environment...

19 rows come back, covering only 10 distinct customers. Ava Coleman and Ella Price each appear three times, once per completed order. The other five customers, the ones whose only orders were cancelled or are still pending, don't appear at all. INNER JOIN doesn't know "customer" is the entity you care about; it only knows pairs that satisfy the ON condition.

LEFT JOIN: the missing five come back as NULL

Change INNER JOIN to LEFT JOIN and run the identical query.

Loading playground environment...

24 rows now: the same 19 matched rows, untouched, plus one new row for each of the five customers who had no completed order, with order_id as NULL. That's the entire effect of LEFT JOIN. It didn't touch Ava's three rows or merge them into one; it only added back the customers that INNER JOIN had dropped entirely.

Keep the status filter in ON, not WHERE

Both queries filter status = 'completed' inside the ON clause. Moving that condition to a WHERE clause changes the LEFT JOIN's meaning entirely, it would discard the five NULL rows you just added back. That specific trap gets a full walkthrough in Six SQL Mistakes Beginners Make.

Why switching sometimes does nothing at all

Drop the status filter and join every customer to every order, no conditions beyond the key:

Loading playground environment...

Both columns return 25. Every single one of the 15 customers in this table has placed at least one order, so there is no unmatched left row for LEFT JOIN to add back. LEFT JOIN only differs from INNER JOIN when an unmatched row actually exists; if your data happens not to have one, the two keywords are interchangeable, and you'll swear you "didn't change anything" when you did. Tutorials never show you this case because their example tables are built so a mismatch always exists.

The row-count bug: COUNT(*) is not COUNT(customers)

This is the part worth re-reading the INNER JOIN result for. It returned 19 rows across 10 customers, and a COUNT(*) on that result reports 19, because COUNT(*) counts rows, not customers.

Loading playground environment...

19 rows, 10 customers. Nothing here is a bug in SQL, the join is doing exactly what it was asked to do, but "how many customers placed a completed order" is a question about entities, and a join's row count answers a question about matches. The moment anyone writes COUNT(*) against a joined result and reports it as a count of customers, orders, or anything on the "one" side of a one-to-many join, the number is wrong by however many extra matches those rows picked up. COUNT(DISTINCT c.id) asks the right question.

This isn't a LEFT JOIN problem, it's a fan-out problem

The inflation comes from any join where one side can match more than once, INNER or LEFT. Swapping join types doesn't fix it, because both keep every matched pair. The fix is always on the aggregate, not the join type.

The same bug shows up in SUM, and it's harder to notice

COUNT(*) being "too high" is easy to spot once you know to look. SUM() giving a plausible-but-wrong number is not, because there's no obviously round figure to compare it against. Say you want the total catalog price of every product that's actually been ordered at least once.

Loading playground environment...

That number is too high, and silently so. A product ordered three times contributes its price three times, because the join produced three rows for it, one per order. The query never asked "how many distinct products," it asked "sum the price column across every matched pair," and a price is not a per-order fact, it's a per-product one. Don't join at all for a per-product total; test for existence instead.

Loading playground environment...

Same question, no fan-out, because EXISTS only ever returns true or false per product, never once per matching order.

When fan-out is fine

None of this means LEFT JOIN or multiplying rows is inherently wrong. A report that wants "every completed order, with the customer's name on each line" needs exactly those duplicated-looking rows, one per order is the correct shape. The bug only appears when a fan-out happens and then gets summed or counted as if it hadn't. If you genuinely want one row per customer with their order count attached, GROUP BY after the join collapses the duplicates back down correctly, which is the pattern the LEFT JOIN lesson walks through in detail. The rule that keeps you safe either way: before trusting any aggregate after a join, ask whether the table on the "one" side could have matched more than once. If it could, COUNT(*) and bare SUM() are counting the wrong thing.

Where to go next

The INNER JOIN and LEFT JOIN lessons cover the syntax and the ON vs WHERE placement rule in full. The LEFT JOIN example builds the "find unmatched rows" anti-join pattern step by step. Once both joins feel predictable, the practice problems are the fastest way to find out whether you'd catch an inflated COUNT or SUM in your own query, before production does it for you.

Cite this resource

SQLSimplified. "INNER JOIN vs LEFT JOIN in SQL, With Real Output". Available at: https://sqlsimplified.online/blog/inner-join-vs-left-join-in-sql