SQL LEFT JOIN vs RIGHT JOIN: Outer Joins and ON vs WHERE
A LEFT JOIN keeps every row of the left table and fills NULLs where no match exists. RIGHT JOIN mirrors it; filtering in ON vs WHERE changes the answer.
- Course: SQL study plan
- Module: Joins
- Kind: Lesson
- Reading time: 16 min
- Runtime: MySQL 8 syntax on SQLite 3.49
What is the difference between LEFT JOIN and INNER JOIN?
An INNER JOIN returns only rows that found a match in both tables. A LEFT JOIN (LEFT OUTER JOIN) returns every row of the left table, matched or not; where the right table has no matching row, its columns come back as NULL. Use a LEFT JOIN whenever rows without a partner, such as customers with no orders, must still appear.
Lesson
An inner join answers "which pairs match?". Many real questions are different: every customer and how much each spent, including the ones who spent nothing; every product and its last sale, if any; every student and their grade, blank if not yet graded. Dropping the unmatched rows would give a wrong report — the customers who never ordered are often exactly the ones the business wants to see. Outer joins keep them. This lesson covers LEFT JOIN, its mirror RIGHT JOIN, counting and summing after an outer join, and the most common outer-join bug in interviews and production code: a condition in the wrong clause.
LEFT JOIN
SELECT c.name, o.id AS order_id, o.amount
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
ORDER BY c.name, o.id;
With the module's data (Asha has orders 10 and 11, Ben has 12, Chen has none, order 13 belongs to a missing customer 9):
customers c LEFT JOIN orders o ON o.customer_id = c.id- LEFT JOIN guarantees every row of the table written first, customers. Each customer is paired with its matching orders, exactly as in an inner join.
- Asha (id 1) matches orders 10 and 11: 2 result rows, one per match.
- Ben (id 2) matches order 12: 1 result row, one per match.
- Chen matches no order. An inner join would drop Chen; a left join keeps one row with NULL in every column that comes from orders.
- Order 13, for the missing customer 9, is still absent: the right side keeps only matched rows. Swap the tables with RIGHT JOIN and it would be the one kept with a NULL name.
The rule: every row of the left table appears at least once. A left row with matches appears once per match, exactly as in an inner join. A left row with no match appears once, with every column of the right table set to NULL. Order 13 is still gone — it is on the right side, and the right side keeps only matched rows.
"Left" simply means the table written before LEFT JOIN. Choosing it is choosing whose rows are guaranteed: here, customers.
RIGHT JOIN is the same thing backwards
SELECT c.name, o.id AS order_id
FROM customers c
RIGHT JOIN orders o ON o.customer_id = c.id
ORDER BY o.id;
| name | order_id |
|---|---|
| Asha | 10 |
| Asha | 11 |
| Ben | 12 |
| NULL | 13 |
Now every order is kept and the orphan order 13 shows a NULL name. A RIGHT JOIN B equals B LEFT JOIN A, so it never adds expressive power. Most style guides write only left joins — the preserved table is then always the one in FROM, which you can see at a glance. You should still read right joins fluently, because they appear in legacy code and interview questions.
Counting after an outer join
SELECT c.name,
COUNT(*) AS wrong_count,
COUNT(o.id) AS orders,
COALESCE(SUM(o.amount), 0) AS spent
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY c.name;
| name | wrong_count | orders | spent |
|---|---|---|---|
| Asha | 2 | 2 | 350 |
| Ben | 1 | 1 | 400 |
| Chen | 1 | 0 | 0 |
Chen's group has one row — the NULL-filled one — so COUNT(*) says 1. COUNT(o.id) counts non-NULL values and says 0, which is the truth. Count a right-side column that is never NULL in a real match (its primary key is ideal). SUM over nothing but NULLs is NULL, not 0; COALESCE(SUM(o.amount), 0) turns it into a number for the report.
The trap: ON versus WHERE
Suppose the report should count only orders from February 2024, but still list every customer.
-- Wrong: the filter is in WHERE
SELECT c.name, COUNT(o.id) AS feb_orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.order_date >= '2024-02-01' AND o.order_date < '2024-03-01'
GROUP BY c.id, c.name
ORDER BY c.name;
| name | feb_orders |
|---|---|
| Asha | 1 |
| Ben | 1 |
Chen vanished. The join produced Chen's NULL-filled row as promised — and then WHERE evaluated NULL >= '2024-02-01', which is unknown, and discarded it. Any WHERE condition on a right-table column that rejects NULL turns the left join back into an inner join.
-- Right: the filter is part of the match
SELECT c.name, COUNT(o.id) AS feb_orders
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.id
AND o.order_date >= '2024-02-01' AND o.order_date < '2024-03-01'
GROUP BY c.id, c.name
ORDER BY c.name;
| name | feb_orders |
|---|---|
| Asha | 1 |
| Ben | 1 |
| Chen | 0 |
count February orders, but list every customer- The left join runs first and keeps Chen as promised, with NULL for every order column, order_date included.
- WHERE then tests each joined row. January's order is FALSE; Chen's NULL date makes NULL >= '2024-02-01' UNKNOWN, and WHERE keeps only TRUE.
- Only the two TRUE rows reach GROUP BY, so Chen has vanished — the WHERE condition quietly turned the left join into an inner join.
- Move the date test into ON and it only decides which orders count as a match. January's order no longer matches Asha, and Chen, matching nothing, is kept with NULLs.
- Counting o.id now gives every customer a row: Asha 1, Ben 1, Chen 0. Right-table filters go in ON; left-table filters go in WHERE.
The rule to memorise:
- a condition on the right (optional) table that decides which rows count as a match belongs in
ON; - a condition on the left (preserved) table that decides which left rows appear at all belongs in
WHERE— putting it inONdoes not filter the left table, it only stops those left rows from matching, and they still appear with NULLs.
-- Only Pune customers, each with their orders (or NULLs)
SELECT c.name, o.id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE c.city = 'Pune'
ORDER BY c.name, o.id;
There is one deliberate exception: WHERE o.id IS NULL after a left join keeps only the unmatched rows. That is the anti-join pattern, covered in the semi- and anti-joins lesson.
Chaining outer joins
Left joins chain left to right: each one keeps every row produced so far.
SELECT c.name, o.id AS order_id, s.shipped_on
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
LEFT JOIN shipments s ON s.order_id = o.id
ORDER BY c.name, o.id;
Chen still appears (NULL order, NULL shipment), and an order with no shipment appears with a NULL date. If the second join were an inner JOIN, its condition s.order_id = o.id would fail for Chen's NULL order, and Chen would disappear — one inner join after a left join quietly undoes it. The joining-many-tables lesson works through that case.
Telling a real NULL from a missing match
If a right-table column can itself be NULL — a shipped_on that is NULL for an order not yet shipped — then a NULL in the result is ambiguous: no matching row, or a matching row with an empty value? Test the right table's primary key instead: s.id IS NULL means no match, because a real row always has its key.
In other databases
LEFT [OUTER] JOIN and RIGHT [OUTER] JOIN are standard and behave identically in PostgreSQL, SQL Server, Oracle and SQLite. Oracle's legacy (+) syntax (WHERE c.id = o.customer_id(+)) is the same left join in old code. The ON-versus-WHERE behaviour is part of the standard, so the trap exists everywhere.
Pitfalls
- Filtering the optional table in
WHEREand losing the unmatched rows. COUNT(*)after a left join counting the NULL row as 1.SUMreturning NULL for "nothing" instead of 0.- An inner join chained after a left join.
- Testing a nullable right-side column to detect "no match".
Key takeaways
LEFT JOINkeeps every left row; unmatched rows get NULLs for the right table's columns.A RIGHT JOIN B=B LEFT JOIN A; prefer writing left joins.- Count a right-side key (
COUNT(o.id)), andCOALESCEsums to 0. - Conditions on the optional table go in
ON; conditions on the preserved table go inWHERE. - Detect "no match" with the right table's primary key being NULL.
Common questions
Why does my LEFT JOIN behave like an INNER JOIN?
A condition on the right table was put in WHERE. For unmatched rows the right table's columns are NULL, so WHERE o.status = 'paid' is unknown and removes them, undoing the outer join. Move the condition into the ON clause, as in LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'.
Should I use COUNT(*) or COUNT(column) after a LEFT JOIN?
Count a column of the right table, such as COUNT(o.id). An unmatched left row still produces one result row, so COUNT(*) counts it as 1. COUNT(o.id) skips NULLs and correctly returns 0 for a customer with no orders.
Is RIGHT JOIN ever necessary?
No. A RIGHT JOIN B returns exactly the rows of B LEFT JOIN A, so any right join can be rewritten by swapping the tables. Most teams write only left joins so that the table whose rows are all kept is always the first one named, which makes queries easier to read.
Is LEFT OUTER JOIN the same as LEFT JOIN?
Yes. OUTER is optional in MySQL, PostgreSQL and SQL Server, so LEFT JOIN and LEFT OUTER JOIN are identical, as are RIGHT JOIN and RIGHT OUTER JOIN. The word only signals that unmatched rows are kept.
Exercises
Every customer's orders and spend
List every customer with the number of orders they placed and the total they spent. Customers with no orders show 0 and 0. An order whose amount is NULL still counts as an order but adds nothing to the total.
Tables: customers(id, name, city) and orders(id, customer_id, order_date, amount).
| customers.id | name | city |
|---|---|---|
| 1 | Asha | Pune |
| 2 | Ben | Delhi |
| 3 | Chen | Pune |
| orders.id | customer_id | order_date | amount |
|---|---|---|---|
| 10 | 1 | 2024-01-05 | 250 |
| 11 | 1 | 2024-02-11 | 100 |
| 12 | 2 | 2024-02-20 | 400 |
| 13 | 9 | 2024-03-01 | 75 |
Output: columns customer, orders, spent, ordered by spent descending, then customer ascending.
customer | orders | spent
Ben | 1 | 400
Asha | 2 | 350
Chen | 0 | 0This term's enrolments for every student
List every student with the number of courses they are enrolled in for the term '2024-S1'. Students with no enrolment in that term show 0. Two students can share a name; they are different students.
Tables: students(id, name) and enrollments(student_id, course_id, term).
| students.id | name |
|---|---|
| 1 | Anu |
| 2 | Bilal |
| 3 | Chitra |
| student_id | course_id | term |
|---|---|---|
| 1 | 101 | 2024-S1 |
| 1 | 102 | 2024-S1 |
| 2 | 101 | 2023-S2 |
| 3 | 103 | 2024-S1 |
| 1 | 103 | 2023-S2 |
Output: columns student, courses, ordered by student, then courses.
student | courses
Anu | 2
Bilal | 0
Chitra | 1In this module: Joins
- INNER JOIN — matching rows across tables
- LEFT and RIGHT JOIN — keeping the rows without a match (this lesson)
- FULL OUTER and CROSS JOIN — both sides, and every pair
- Self joins — a table joined to itself
- Joining three or more tables
- Semi joins and anti joins — "has a match" and "has none"
- Checkpoint — Joins
← INNER JOIN — matching rows across tables · FULL OUTER and CROSS JOIN — both sides, and every pair →