Skip to main content
PProfileMatch

SQL JOINs Explained: INNER vs LEFT JOIN and the Row Multiplication Trap

Data AnalyticsLesson 3 of 46 min read

What you'll learn

  • Explain what a JOIN does using primary and foreign keys
  • Write INNER JOIN and LEFT JOIN queries and predict which rows survive
  • Find rows with no match using a LEFT JOIN anti-join
  • Spot and fix the row-multiplication trap when joining two one-to-many tables

On this page

  1. Why data is split across tables
  2. INNER JOIN: only rows that match on both sides
  3. LEFT JOIN: keep every row from the left table
  4. The row-multiplication trap
  5. Common mistakes
  6. How this shows up in real projects and interviews

A podcast network asks for a report: for each show, the number of episodes, the total minutes and the number of sponsors. The query you would write first looks perfectly reasonable. It also returns "Night Shift Radio: 6 episodes, 200 minutes", when the show has three episodes totalling 100 minutes, and it silently drops the show with no episodes at all. Both errors come from how joins combine rows.

Why data is split across tables

Putting everything in one wide table repeats data. A show's title would be copied onto every episode and every sponsor row, and renaming a show would mean editing dozens of rows. Relational design instead stores each fact once and links tables with keys: episodes.show_id is a foreign key pointing to shows.show_id. A JOIN is the operation that stitches the pieces back together at query time.

The schema below has one parent table and two child tables. A show has many episodes and many sponsors, but episodes and sponsors are unrelated to each other, which matters at the end of the lesson.

podcasts.sql
CREATE TABLE shows (
  show_id INTEGER PRIMARY KEY,
  title   TEXT NOT NULL
);

CREATE TABLE episodes (
  episode_id INTEGER PRIMARY KEY,
  show_id    INTEGER NOT NULL REFERENCES shows(show_id),
  title      TEXT    NOT NULL,
  minutes    INTEGER NOT NULL
);

CREATE TABLE sponsors (
  sponsor_id INTEGER PRIMARY KEY,
  show_id    INTEGER NOT NULL REFERENCES shows(show_id),
  name       TEXT    NOT NULL
);

INSERT INTO shows VALUES
  (1, 'Night Shift Radio'), (2, 'Soil and Soul'),
  (3, 'The Long Commute'),  (4, 'Half Past Noon');

INSERT INTO episodes VALUES
  (1, 1, 'The 3am Bakery',     32),
  (2, 1, 'Bridge Inspectors',  41),
  (3, 1, 'Lighthouse Relief',  27),
  (4, 2, 'Compost Wars',       25),
  (5, 2, 'Seeds of Doubt',     38),
  (6, 3, 'Platform 9 Stories', 45);

INSERT INTO sponsors VALUES
  (1, 1, 'Brightline Torches'),
  (2, 1, 'Kettle & Co'),
  (3, 2, 'GreenGrip Gloves'),
  (4, 3, 'Kettle & Co'),
  (5, 3, 'RailSnack'),
  (6, 3, 'Moonpost Mattresses');

INNER JOIN: only rows that match on both sides

INNER JOIN ... ON pairs each episode row with the show row whose key matches, and keeps only pairs where the condition is true. Aliases (s, e) keep the query readable, and prefixing columns with them avoids ambiguity when both tables have a title.

sql
SELECT s.title AS show, e.title AS episode, e.minutes
FROM shows AS s
INNER JOIN episodes AS e ON e.show_id = s.show_id
ORDER BY s.show_id, e.episode_id;

Output

show              | episode            | minutes
------------------+--------------------+--------
Night Shift Radio | The 3am Bakery     |      32
Night Shift Radio | Bridge Inspectors  |      41
Night Shift Radio | Lighthouse Relief  |      27
Soil and Soul     | Compost Wars       |      25
Soil and Soul     | Seeds of Doubt     |      38
The Long Commute  | Platform 9 Stories |      45

Six episodes produce six rows, each carrying its show's title. The show Half Past Noon has no episodes, so no pair exists for it and it is absent. That is the defining behaviour of an inner join: a row without a partner disappears.

LEFT JOIN: keep every row from the left table

LEFT JOIN keeps all rows from the table on the left, and fills the right-hand columns with NULL where there is no match. Use it when the absence of related rows is itself information. Adding WHERE right_key IS NULL turns it into an anti-join, a clean way to ask "which shows have nothing attached?"

sql
SELECT s.title AS show, e.title AS episode
FROM shows AS s
LEFT JOIN episodes AS e ON e.show_id = s.show_id
ORDER BY s.show_id, e.episode_id;

-- anti-join: shows with no episodes at all
SELECT s.title
FROM shows AS s
LEFT JOIN episodes AS e ON e.show_id = s.show_id
WHERE e.episode_id IS NULL;

Output

show              | episode
------------------+-------------------
Night Shift Radio | The 3am Bakery
Night Shift Radio | Bridge Inspectors
Night Shift Radio | Lighthouse Relief
Soil and Soul     | Compost Wars
Soil and Soul     | Seeds of Doubt
The Long Commute  | Platform 9 Stories
Half Past Noon    | NULL

title
--------------
Half Past Noon
Join typeUnmatched left rowsUnmatched right rowsTypical use
INNER JOINDroppedDroppedOnly complete pairs matter
LEFT JOINKept, right side NULLDroppedList everything on the left, with extras if present
RIGHT JOINDroppedKept, left side NULLMirror of LEFT; most people just swap table order
FULL OUTER JOINKeptKeptReconciling two lists (MySQL has no FULL JOIN; SQLite added RIGHT and FULL in version 3.39)
sql
-- filter in ON: every show survives
SELECT s.title AS show, e.title AS episode
FROM shows AS s
LEFT JOIN episodes AS e ON e.show_id = s.show_id AND e.minutes > 40
ORDER BY s.show_id, e.episode_id;

-- same filter in WHERE: the outer join collapses into an inner join
SELECT s.title AS show, e.title AS episode
FROM shows AS s
LEFT JOIN episodes AS e ON e.show_id = s.show_id
WHERE e.minutes > 40
ORDER BY s.show_id, e.episode_id;

Output

show              | episode
------------------+-------------------
Night Shift Radio | Bridge Inspectors
Soil and Soul     | NULL
The Long Commute  | Platform 9 Stories
Half Past Noon    | NULL

show              | episode
------------------+-------------------
Night Shift Radio | Bridge Inspectors
The Long Commute  | Platform 9 Stories

The first query keeps all four shows and shows an episode only where it lasts more than 40 minutes. The second applies the same condition after the join, so shows with NULL minutes are eliminated: Soil and Soul and Half Past Noon vanish. Both are legitimate queries, but they answer different questions.

The row-multiplication trap

Start with the safe case: counting episodes and minutes per show with a single left join. The result is correct and includes the show with no episodes, thanks to COALESCE replacing the NULL sum.

sql
SELECT s.title AS show,
       COUNT(e.episode_id)          AS episodes,
       COALESCE(SUM(e.minutes), 0)  AS total_minutes
FROM shows AS s
LEFT JOIN episodes AS e ON e.show_id = s.show_id
GROUP BY s.show_id
ORDER BY s.show_id;

Output

show              | episodes | total_minutes
------------------+----------+--------------
Night Shift Radio |        3 |           100
Soil and Soul     |        2 |            63
The Long Commute  |        1 |            45
Half Past Noon    |        0 |             0

Now add sponsors to the same query by joining a second child table. The report asks for episodes and minutes again, and the result is wrong.

sql
SELECT s.title AS show,
       COUNT(*)       AS episodes,
       SUM(e.minutes) AS total_minutes
FROM shows AS s
JOIN episodes AS e ON e.show_id = s.show_id
JOIN sponsors AS p ON p.show_id = s.show_id
GROUP BY s.show_id
ORDER BY s.show_id;

Output

show              | episodes | total_minutes
------------------+----------+--------------
Night Shift Radio |        6 |           200
Soil and Soul     |        2 |            63
The Long Commute  |        3 |           135

Night Shift Radio shows 6 episodes and 200 minutes (the truth is 3 and 100), The Long Commute shows 3 episodes and 135 minutes (the truth is 1 and 45), and Half Past Noon is gone. The reason is that a join produces one row per combination. A show with 3 episodes and 2 sponsors yields 3 x 2 = 6 rows, so each episode is counted once per sponsor. Soil and Soul happens to look right only because it has exactly one sponsor, which is the sneakiest version of this bug: it passes a quick check on some rows.

There are two sound fixes. You can aggregate each child table separately, in subqueries, and join the already-summarised results, as below. Or, for counts, use COUNT(DISTINCT e.episode_id). Be careful with SUM(DISTINCT e.minutes): it would collapse two episodes of equal length into one and is not a fix at all.

sql
SELECT s.title AS show, ep.total_minutes, sp.sponsors
FROM shows AS s
LEFT JOIN (SELECT show_id, SUM(minutes) AS total_minutes
           FROM episodes GROUP BY show_id) AS ep ON ep.show_id = s.show_id
LEFT JOIN (SELECT show_id, COUNT(*) AS sponsors
           FROM sponsors GROUP BY show_id) AS sp ON sp.show_id = s.show_id
ORDER BY s.show_id;

Output

show              | total_minutes | sponsors
------------------+---------------+---------
Night Shift Radio |           100 |        2
Soil and Soul     |            63 |        1
The Long Commute  |            45 |        3
Half Past Noon    |          NULL |     NULL

Each subquery returns at most one row per show, so no multiplication can occur, and the left joins keep every show. Half Past Noon appears with NULLs; wrap them in COALESCE(..., 0) if the report needs zeros. A related surprise is the accidental cross join: listing two tables with a comma, or joining without a matching ON, pairs every row with every row.

sql
SELECT COUNT(*) AS cross_rows FROM shows, episodes;

Output

cross_rows
----------
        24

Four shows times six episodes gives 24 rows, with no relationship between them. On two tables of a million rows each, that would be a trillion rows, which is why a forgotten join condition can bring a database to its knees.

Common mistakes

  • Joining two one-to-many tables, then aggregating. Symptom: sums and counts that are multiples of the right answer. Aggregate each child separately first.
  • Right-table filter in WHERE after a LEFT JOIN. Symptom: rows you expected to see with NULLs are missing. Move the filter into ON.
  • Forgetting the ON condition. Symptom: result row counts equal the product of the table sizes, and queries are suddenly slow.
  • Ambiguous column names. Error: ambiguous column name: title (SQLite). Prefix columns with their alias.
  • Joining on non-unique columns by accident, such as a name instead of an id. Duplicate names create extra pairs. Join on keys.

How this shows up in real projects and interviews

Reporting queries that quietly double-count are among the most common data bugs in real teams, usually discovered when finance disagrees with a dashboard. Interviewers like asking "what happens to rows with no match" and "why is my SUM too big". A good habit is to check the row count after each join: if it grew when you did not expect it to, find out why before you aggregate. Joins perform well only when the key columns are indexed, which is the subject of Database Indexing. Later, Advanced SQL shows window functions that avoid some of these self-joins entirely.

Try it yourself

Write a query that lists every sponsor name together with how many shows it sponsors, using the podcast tables, and shows only sponsors that back more than one show. Then write a second query that returns the shows with no sponsor. Finally join shows to both episodes and sponsors without aggregating first and compare the row count with COUNT(*) from each table.

Show a hint

Kettle & Co is the only sponsor name that appears on two shows. For the shows with no sponsor use LEFT JOIN sponsors and test p.sponsor_id IS NULL. The unaggregated double join returns one row per episode-sponsor pair for each show.

Key takeaways

  • A foreign key stores the link; a JOIN uses it at query time to combine rows from two tables.
  • INNER JOIN drops rows without a partner; LEFT JOIN keeps every left row and fills the missing side with NULL.
  • Use LEFT JOIN plus IS NULL on the right key to find rows with no match, and keep right-table filters in ON.
  • Joining two one-to-many tables multiplies rows; aggregate each side separately or count distinct keys.

Test yourself

A trick from this lesson's ride: INNER JOIN keeps couples only: no partner, no row. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between INNER JOIN and LEFT JOIN?

INNER JOIN returns only rows that have a match on both sides. LEFT JOIN returns every row from the left table and fills the right-side columns with NULL when there is no match. Use LEFT JOIN when the absence of related rows matters.

Why does my SQL JOIN return too many rows or inflated SUM values?

A join returns one row per matching combination. If you join a parent to two one-to-many child tables, each row of one child repeats for every row of the other, which inflates counts and sums. Aggregate each child separately before joining, or use COUNT(DISTINCT key).

How do I find rows in one table that have no match in another?

Use a LEFT JOIN from the first table to the second and keep rows where the second table's key IS NULL. Another form is NOT EXISTS with a correlated subquery. Both return the same rows, and the LEFT JOIN version is easy to read.