Devpuff
Pricing
Log InStart Free
  1. Home
  2. Blog
  3. SQL joins explained, which rows survive and why
Learn

SQL joins explained, which rows survive and why

INNER, LEFT, RIGHT, FULL and CROSS joins on the same two tables, with the real result rows. Plus why the Venn diagram everyone shows you is misleading.

July 21, 2026·9 Min read·By The Devpuff team
Two database tables side by side with lines connecting matching rows, and the joined result set below them

A SQL join combines rows from two tables by matching a value they share. Which join you pick decides what happens to the rows that do not match: an INNER JOIN drops them, a LEFT JOIN keeps the left table's, a RIGHT JOIN keeps the right table's, and a FULL OUTER JOIN keeps both, filling the gaps with NULL.

That is the whole concept. Everything difficult about joins comes from row counts, not from the syntax.

Which is a problem, because the picture everyone learns joins from is a Venn diagram, and a Venn diagram cannot show a row count. This post uses two small tables and the actual output instead. Every result below was produced by running the query against SQLite 3.49, so the rows you see are the rows the database returned.

The two tables

Three customers, four orders. One of those orders is a mess, deliberately.

customers

id name city
1 Ada Lagos
2 Linus Oslo
3 Grace Lima

orders

id customer_id total
101 1 40
102 1 15
103 2 90
104 9 25

Two facts to keep in mind, because every result below turns on them:

  • Ada has two orders. She is one customer and two rows in orders.
  • Grace has none, and order 104 belongs to customer 9, who does not exist. One unmatched row on each side.

INNER JOIN: only the matches

SELECT c.name, o.id AS order_id, o.total
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id
ORDER BY c.name, o.id;
name order_id total
Ada 101 40
Ada 102 15
Linus 103 90

3 rows. Grace is missing because she has no orders. Order 104 is missing because it has no customer.

INNER is the default, so JOIN on its own means this. It is the join to use when a row without a match is not interesting, which covers most reporting queries.

Notice Ada appears twice already. Hold that thought.

LEFT JOIN: keep everything on the left

SELECT c.name, o.id AS order_id, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
ORDER BY c.name, o.id;
name order_id total
Ada 101 40
Ada 102 15
Grace NULL NULL
Linus 103 90

4 rows. Grace is back, with NULL in every column that came from orders.

"Left" means the table in the FROM clause, the one written first. Every one of its rows is guaranteed to appear at least once. Where there was no match, the right table's columns are NULL.

This is the join for questions shaped like "all X, with their Y if they have any". All customers with their order totals. All students with their grades. All products with their reviews. If a report is missing the rows with zero of something, a LEFT JOIN is usually the fix.

Order 104 is still gone. LEFT JOIN protects one side only.

RIGHT JOIN: keep everything on the right

SELECT c.name, o.id AS order_id, o.total
FROM customers c
RIGHT JOIN orders o ON o.customer_id = c.id
ORDER BY c.name, o.id;
name order_id total
NULL 104 25
Ada 101 40
Ada 102 15
Linus 103 90

4 rows, and now the orphan shows up: order 104, 25 in value, belonging to nobody. Grace is gone instead.

RIGHT JOIN is LEFT JOIN with the tables the other way round, and that is exactly how most people write it: swap the table order and use LEFT. Reading a query top to bottom where "left" always means "the one above" is easier on the next person.

The one thing RIGHT JOIN is genuinely good for is what happened here, finding data that should not exist. An order pointing at a missing customer is a broken foreign key, and it is the sort of thing that hides for months.

FULL OUTER JOIN: keep everything

SELECT c.name, o.id AS order_id, o.total
FROM customers c
FULL OUTER JOIN orders o ON o.customer_id = c.id
ORDER BY c.name, o.id;
name order_id total
NULL 104 25
Ada 101 40
Ada 102 15
Grace NULL NULL
Linus 103 90

5 rows: the 3 matches, plus Grace, plus order 104. Nothing is dropped from either side.

This is the reconciliation join. Two systems that should agree, and you want every disagreement in both directions.

One portability note: PostgreSQL, SQL Server, Oracle and modern SQLite all support it. MySQL does not, and the workaround there is a LEFT JOIN and a RIGHT JOIN glued together with UNION.

CROSS JOIN: every combination

SELECT COUNT(*) FROM customers CROSS JOIN orders;
12

3 customers times 4 orders. No ON clause, no matching, just every possible pairing.

You will rarely write one on purpose. It is useful for generating grids, like every product in every size. Mostly it matters because you can produce one by accident, and a CROSS JOIN between two real tables is how a query that used to take a second starts taking an hour.

Why the Venn diagram is misleading

Here is the thing the circles cannot show you.

A Venn diagram says a join is about set membership: which items are in A, in B, or in both. But joins do not work on membership. They work on row pairing, and a single row on one side can pair with many rows on the other.

Watch what happens when we select only customer columns:

SELECT c.name, c.city
FROM customers c
JOIN orders o ON o.customer_id = c.id;
name city
Ada Lagos
Ada Lagos
Linus Oslo

Two identical Ada rows. We asked for customers and got a duplicate, because Ada has two orders and the join produced one row per order.

There is no region of a Venn diagram that represents "Ada, twice". The picture has no way to express it, which is why people who learned joins from circles are surprised the first time a report double counts.

And it does double count:

SELECT COUNT(*) AS customer_count
FROM customers c
JOIN orders o ON o.customer_id = c.id;
3

Three, from a table containing two matching customers. Counting rows after a join counts pairings, not customers.

The fix is to say what you actually mean:

SELECT COUNT(DISTINCT c.id) AS customer_count
FROM customers c
JOIN orders o ON o.customer_id = c.id;
2

If a total ever comes out suspiciously high after you add a join, this is why. The join multiplied your rows and the SUM obediently added the duplicates.

The WHERE clause that quietly kills your LEFT JOIN

This is the most common join bug in production code, and it produces a plausible-looking wrong answer rather than an error.

SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.total > 20;
name total
Ada 40
Linus 90

Grace is gone, despite the LEFT JOIN that exists specifically to keep her.

The reason is ordering. The join runs first and produces Grace's row with NULL in o.total. Then WHERE runs, and NULL > 20 is not true, so the row is filtered out. Your LEFT JOIN just became an INNER JOIN.

Move the condition into the ON clause and it applies while pairing rather than after:

SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.total > 20;
name total
Ada 40
Grace NULL
Linus 90

Three rows. Grace is preserved with no matching order, which is what "all customers, plus their big orders if any" should mean.

The rule: conditions on the right-hand table of an outer join belong in ON, not WHERE. Conditions on the left table can go in either.

Finding the rows with no match

Combine the two ideas and you get one of the most useful queries there is:

SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
Grace

Keep everything on the left, then keep only the rows where the right side came back empty. Customers who never ordered. Posts with no comments. Users who never finished onboarding.

Flip it to audit the other direction:

SELECT o.id, o.customer_id
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE c.id IS NULL;
id customer_id
104 9

There is the broken row, named exactly.

Picking a join

The question The join
Only rows that match on both sides INNER JOIN
Every row from the first table, matches where they exist LEFT JOIN
Rows from the first table with no match at all LEFT JOIN + WHERE right.id IS NULL
Everything from both sides, gaps included FULL OUTER JOIN
Every possible combination CROSS JOIN
Every row from the second table LEFT JOIN, with the tables swapped

Then check two things before you trust the result: did the row count change more than you expected, and is anything being counted or summed after the join?

Practice on a real database

Joins are the point where SQL stops being obvious, and reading result tables is not the same as writing the query that produced them.

SQL Basics runs a real SQLite database in your browser, so you can write a join, see the rows, add a WHERE, and watch a row disappear. No install, no server, no sample database to download.

Related: How Websites Work for where the database sits in the request, and the SQL hub for the full path from SELECT onward.

Keep reading

More from the blog

A Python for loop printing an index next to each item in a list, with enumerate supplying both values
July 21, 2026·11 min readLearn

Python for loop with index, enumerate() explained

Use enumerate() to get the index and the item in one loop. The syntax, the start argument, and why range(len()) breaks on half of what you loop over.

Read more
A function shown holding on to a variable from the outer function that created it, after that outer function has returned
July 21, 2026·10 min readLearn

JavaScript closures explained with 3 examples

A closure is a function that remembers the variables around it after the function that made them has finished. Three real uses and the classic loop bug.

Read more
{ }
✦

Ready to write some code?

Put this into practice - start your first free lesson. No setup, no credit card.

Start learning free
Devpuff

Learn to code by doing. One tiny, playful lesson at a time.

Learn
ProgramsCoursesPricing
Company
AboutBlogResources
Support
Help CenterContactStatus
Programs
Frontend Development
Courses
HTML Basics
Learn to Code
Learn JavaScriptLearn SQLLearn HTMLLearn CSSLearn ReactBrowse All Topics
Platform Comparisons
Devpuff vs CodecademyDevpuff vs MimoDevpuff vs SololearnDevpuff vs freeCodeCampRead All Comparisons
© 2026 Devpuff. All rights reserved.Privacy PolicyTerms and ConditionsCookies PolicyRefund Policy