SQL example

Inner, left, right and full outer joins in SQL

7 min read Updated Oct 2026 Runs in an isolated runtime
Quick answer

INNER JOIN (or plain JOIN) returns only the rows that match in both tables. An outer join also keeps the rows that have no match and fills the missing side with NULL: LEFT JOIN keeps every row of the first table, RIGHT JOIN every row of the second, and FULL OUTER JOIN every row of both. OUTER is optional, so LEFT JOIN and LEFT OUTER JOIN are the same join. MySQL has no FULL JOIN; combine a LEFT JOIN and a RIGHT JOIN with UNION ALL.

A join pairs rows from two tables using a condition in ON. The join types only differ in what happens to a row that finds no partner: an inner join drops it, an outer join keeps it. To make that visible, every example on this page uses the same two tables: three customers, one of whom (Cleo) has never ordered, and four orders, one of which (104) belongs to a customer id that doesn't exist. Compare the result tables from one section to the next and the difference between the joins is exactly those two rows. The examples run on SQLite; the table in section 7 gives the PostgreSQL, MySQL and SQL Server forms. Hit Run on any of them, then change the data and run it again.

1INNER JOIN: only the rows that matchRecommended

INNER JOIN returns one row for every pair of rows where the ON condition is true. A customer with two orders appears twice, a customer with no orders doesn't appear, and an order whose customer is missing doesn't appear either. Writing JOIN without a type means INNER JOIN in every major database; the keyword INNER is only there for readability.

inner-join.sql

Output

Three rows come back: Ana's orders 101 and 102 and Ben's order 103. Cleo and order 104 are both missing, because neither has a partner on the other side. The bare JOIN counts the same 3 rows. Inner join is the right default when the data has to exist on both sides, such as an order line with its product; the result is also the same whichever table you write first.

2LEFT JOIN: every row from the first table

LEFT JOIN returns everything an inner join returns, plus each row of the left table (the one before the keyword) that matched nothing. The columns from the right table are NULL on those extra rows. LEFT OUTER JOIN is the same join written longhand. It is the join to reach for when the first table is the list you care about, for example every customer with their order count, including the ones at zero.

left-join.sql

Output

The first query adds a fourth row, Cleo | NULL | NULL, to the inner join's three. Order 104 is still missing: a left join keeps unmatched rows from the left table only. In the summary, count(o.id) gives Cleo 0 because count(column) skips NULLs, whereas count(*) would count her one padded row as 1. sum of nothing is NULL, so coalesce(..., 0) turns her total into 0.

3RIGHT JOIN: every row from the second table

RIGHT JOIN (or RIGHT OUTER JOIN) is the mirror image: it keeps every row of the table after the keyword. Any right join can be rewritten as a left join by swapping the two tables, and most people do that, since queries read more easily when the main table comes first. SQLite has supported RIGHT and FULL joins since version 3.39 (2022).

right-join.sql

Output

Both queries print the same four rows. This time the orphan order 104 is kept with NULL for the name, and Cleo is gone, because she is on the side that isn't preserved. A row like 104 is usually a data problem; a foreign key (in SQLite, with PRAGMA foreign_keys = ON) would have refused to insert it.

4FULL OUTER JOIN: every row from both tables

FULL OUTER JOIN (or FULL JOIN) combines the two previous results: all the matches, the left rows with no partner and the right rows with no partner. It is the join for comparing two lists, such as reconciling a bank export against your own records. The second query below builds the same rows without FULL JOIN, which is how you write it in MySQL: the left join's rows, plus the right join's rows that found no customer.

full-outer-join.sql

Output

The full join returns five rows: the three matches, Cleo | NULL | NULL and NULL | 104 | 15. The emulation returns the same five; only the order differs, because a UNION can be sorted only by its output columns (sorting by name IS NULL there fails with 1st ORDER BY term does not match any column in the result set) and SQLite sorts NULL first. The WHERE c.id IS NULL on the second half matters: it keeps the matched rows from being listed twice. Shortening it to a plain UNION without that filter also works on this data, but UNION removes duplicate rows, so two genuinely identical rows would collapse into one. See UNION vs UNION ALL.

5ON vs WHERE: how a filter turns a LEFT JOIN into an inner join

For an inner join it makes no difference whether a condition sits in ON or WHERE. For an outer join it does. A condition in ON decides which rows count as a match, and the preserved table keeps all its rows anyway. A condition in WHERE runs after the join, on the padded rows too, and a comparison with NULL is never true, so those rows are filtered out.

on-vs-where.sql

Output

With the condition in ON, order 102 (25) no longer matches, but Cleo is still listed with NULLs. With the same condition in WHERE, Cleo's row has total = NULL, NULL >= 30 isn't true, and she disappears: the result is what an inner join would return. Rule of thumb: conditions on the optional table go in ON; conditions on the preserved table, or a deliberate WHERE o.id IS NULL to keep only the unmatched rows, go in WHERE. That last pattern has its own page: find rows with no match.

6Which join do you need?

The row counts are from the customers and orders tables above: 3 matches, 1 customer with no orders, 1 order with no customer.

JoinReturnsOn our tablesUse it for
INNER JOIN (or JOIN)Matches only3 rowsData that must exist on both sides
LEFT [OUTER] JOINAll left rows + matches4 rows (Cleo with NULLs)Every row of the main table, with optional details
RIGHT [OUTER] JOINAll right rows + matches4 rows (order 104 with NULLs)Rarely: swap the tables and use LEFT JOIN
FULL [OUTER] JOINAll rows from both5 rowsComparing two lists, orphans on either side

7Joins in PostgreSQL, MySQL and SQL Server

The join keywords are standard SQL, so the queries above run unchanged on PostgreSQL and SQL Server (Oracle supports the same keywords too; very old Oracle code uses the (+) operator instead). The exception is MySQL, which has no FULL JOIN. Worse, it doesn't always reject it: FROM customers FULL JOIN orders reads FULL as an alias for customers, warns Using FULL as unquoted identifier is deprecated and then fails with Unknown column 'customers.name'. With aliases (customers AS c FULL OUTER JOIN) it is a plain syntax error.

SyntaxDatabaseNotes
JOIN / INNER JOINPostgreSQL, MySQL, SQL Server, SQLiteIdentical everywhere; INNER is optional
LEFT [OUTER] JOINPostgreSQL, MySQL, SQL Server, SQLiteOUTER is optional everywhere
RIGHT [OUTER] JOINPostgreSQL, MySQL, SQL Server, SQLite 3.39+Same as LEFT JOIN with the tables swapped
FULL [OUTER] JOINPostgreSQL, SQL Server, SQLite 3.39+Not in MySQL (syntax error)
LEFT JOIN … UNION ALL … RIGHT JOIN … WHERE c.id IS NULLMySQLFULL JOIN emulation; keeps duplicate rows
LEFT JOIN … UNION … RIGHT JOIN …MySQLShorter, but UNION also merges identical rows

Frequently asked questions

What is the difference between INNER JOIN and OUTER JOIN?

An inner join returns only the rows that match in both tables. An outer join also returns the rows that found no match, with NULL in the columns from the other table: LEFT JOIN keeps unmatched rows from the first table, RIGHT JOIN from the second and FULL OUTER JOIN from both. With three customers, four orders, one customer without orders and one order without a customer, the inner join returns 3 rows, the left and right joins 4 and the full join 5.

Is LEFT JOIN the same as LEFT OUTER JOIN?

Yes. OUTER is an optional keyword, so LEFT JOIN and LEFT OUTER JOIN are the same join and return the same rows in every database; the same goes for RIGHT JOIN / RIGHT OUTER JOIN and FULL JOIN / FULL OUTER JOIN.

Is JOIN the same as INNER JOIN?

Yes. A JOIN with no type in front is an inner join in SQLite, PostgreSQL, MySQL and SQL Server. FROM customers AS c JOIN orders AS o ON o.customer_id = c.id and the same query with INNER JOIN both returned 3 rows on the example tables.

How do I do a FULL OUTER JOIN in MySQL?

MySQL has no FULL JOIN, so take the LEFT JOIN rows and add the RIGHT JOIN rows that have no match on the left: SELECT ... FROM a LEFT JOIN b ON ... UNION ALL SELECT ... FROM a RIGHT JOIN b ON ... WHERE a.id IS NULL. Writing plain UNION without the WHERE also works, but it drops genuine duplicates: when Ana had two orders of 40, selecting name and total that way returned Ana | 40 once instead of twice.

Why does my LEFT JOIN return the same rows as an INNER JOIN?

Usually because a condition on the right-hand table is in WHERE. For the rows that found no match, that table's columns are NULL, and a test such as WHERE o.total >= 30 is not true for NULL, so those rows are filtered out. Move the condition into the join: LEFT JOIN orders AS o ON o.customer_id = c.id AND o.total >= 30.

How do I get only the rows that have no match on either side?

Use a full join and keep the rows where one side is NULL: FROM customers AS c FULL JOIN orders AS o ON o.customer_id = c.id WHERE c.id IS NULL OR o.id IS NULL. On the example tables that returns Cleo (no orders) and order 104 (no customer). For one side only, use a LEFT JOIN with WHERE o.id IS NULL, or NOT EXISTS.

Run it yourself

Open any of these joins in the full SQLite editor to change the tables and run them again.

SQLite playground