The FULL OUTER JOIN completes the outer-join family: it keeps every row of both tables, matching or not. It's a LEFT JOIN and a RIGHT JOIN in a single operation.

It's also, by some margin, the JOIN you'll write least often. And for an interesting reason this lesson explains: in a database with properly declared referential integrity, like GreenStore's, orphans can only exist on one side. A FULL OUTER JOIN between two tables related by a foreign key almost always degenerates into a LEFT JOIN. Its real playing field is somewhere else: the reconciliation of two independent data sources, where neither of the two has authority over the other.

Contents

  1. The rule and the set diagram
  2. FULL OUTER JOIN over GreenStore: why orphans only appear on one side
  3. The real case: reconciling two data sources
  4. The "only what doesn't match on either side" pattern
  5. Emulation on engines that don't support it
  6. Support by engine, and cost
  7. Common Mistakes and Tips
  8. Exercises
  9. Conclusion

  1. The rule and the set diagram

flowchart LR
    subgraph R[" "]
        direction LR
        A(("only on the left<br/>✅ with NULL on the right"))
        I(("they match<br/>✅ result"))
        B(("only on the right<br/>✅ with NULL on the left"))
    end
    style A fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
    style I fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
    style B fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px

The complete picture of the family, now with all four types:

Type Orphan on the left They match Orphan on the right Rows in the result
INNER JOIN matches
LEFT JOIN matches + left orphans
RIGHT JOIN matches + right orphans
FULL OUTER JOIN matches + left orphans + right orphans

Syntax: FULL OUTER JOIN and FULL JOIN are the same thing, because OUTER is optional just as in LEFT and RIGHT. In this course we write the full FULL OUTER JOIN, because its rarity justifies being explicit.

And a property that sets it apart from the other two outer joins: the FULL OUTER JOIN is commutative. A FULL OUTER JOIN B and B FULL OUTER JOIN A return the same set of rows, because both sides get the same treatment.

Property INNER LEFT RIGHT FULL OUTER
Commutative

  1. FULL OUTER JOIN over GreenStore: why orphans only appear on one side

Let's try the most obvious case: catalogue against sales.

SELECT p.id AS product_id,
       p.name AS product,
       ol.id AS line_id,
       ol.quantity
FROM products AS p
FULL OUTER JOIN order_lines AS ol ON ol.product_id = p.id
ORDER BY p.id, ol.id;

50 rows. Exactly the same as the LEFT JOIN in 03-03 returned:

Query Rows
products INNER JOIN order_lines 47
products LEFT JOIN order_lines 50
products FULL OUTER JOIN order_lines 50
products RIGHT JOIN order_lines 47

The FULL OUTER JOIN hasn't contributed a single row over the LEFT JOIN. Why?

Because for an orphan to appear on the right side there would have to exist an order line whose product_id corresponded to no product. And that's precisely what the foreign key prevents:

product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE RESTRICT

Two constraints act at once:

Constraint What it prevents
REFERENCES products(id) Inserting a line with a product_id that doesn't exist in products
NOT NULL Inserting a line with no product_id
ON DELETE RESTRICT Deleting a product that has lines, leaving them orphaned
flowchart LR
    A["Can there be a product<br/>with no sales lines?"] -->|"Yes: 13, 19 and 20"| B["orphans<br/>on the LEFT"]
    C["Can there be a line<br/>with no product?"] -->|"No: the FK prevents it"| D["orphans on the RIGHT:<br/>impossible"]

A very useful general rule comes out of that:

Between two tables joined by a NOT NULL foreign key with declared referential integrity, a FULL OUTER JOIN is always equivalent to a LEFT JOIN from the parent table. Writing it is redundant, and on top of that it pays a cost it doesn't need.

The same happens with customers and orders: orders.customer_id is NOT NULL REFERENCES customers(id), so customers FULL OUTER JOIN orders returns the same 23 rows as the LEFT JOIN.

The only case in GreenStore where a FULL OUTER would contribute something is orders with employees, because employee_id allows NULL:

  • Orphans on the left: the 10 web orders with no employee.
  • Orphans on the right: the 5 employees with no orders.
  • Total: 10 pairings + 10 + 5 = 25 rows.

It's the only FULL OUTER JOIN that makes sense in this schema, and even so it would be clearer to write two separate queries: "orders with no sales rep" and "employees with no orders" answer different business questions, and mixing them in one table with nulls on both sides helps nobody.

  1. The real case: reconciling two data sources

The FULL OUTER JOIN shines when the two tables are not joined by a foreign key: when they're two independent sources that ought to agree and you have to work out where they don't. Common examples:

Reconciliation Source A Source B What you're looking for
Catalogue against external sales Own catalogue A marketplace file SKUs that don't exist, products not published
Inventory against accounting Physical warehouse count Accounting system Stock discrepancies
Payroll against directory HR system User directory Joiners and leavers not propagated
Payments against invoices Bank statement Invoices issued Payments with no invoice, invoices with no payment

In all of them the key is the same: neither of the two sources is the authority. Each can have rows the other lacks, and the report's goal is precisely to find them.

The scenario: marketplace sales

GreenStore has started selling on an external marketplace too, which every month sends a CSV file with the units sold per SKU. We load that file into a temporary table:

Warning: marketplace_sales isn't part of the GreenStore schema. It's an auxiliary table created only for this example. Don't use it in the rest of the course's exercises, and drop it when you're done (or disconnect: being TEMP, it vanishes with the session).

CREATE TEMP TABLE marketplace_sales (
    sku   INTEGER,
    units INTEGER
);

INSERT INTO marketplace_sales (sku, units) VALUES
( 1, 14), ( 2,  9), ( 3,  6), ( 4, 11), ( 5, 22),
( 6,  4), ( 7,  7), ( 8,  3), ( 9, 12), (10,  5),
(11,  8), (12,  6), (14, 15), (15,  2), (16, 10),
(17,  4), (18, 19), (101, 7), (102, 3);

19 rows. Notice two things: there's no foreign key at all between sku and products.id —the file comes from another system, we can't impose constraints on it— and two strange SKUs show up, 101 and 102.

The complete reconciliation

SELECT p.id   AS product_id,
       p.name AS product,
       ms.sku,
       ms.units
FROM products AS p
FULL OUTER JOIN marketplace_sales AS ms ON ms.sku = p.id
ORDER BY COALESCE(p.id, ms.sku);
product_id product sku units
1 Extra virgin olive oil 500 ml 1 14
2 Organic brown rice 1 kg 2 9
3 Raw orange blossom honey 500 g 3 6
4 Spelt pasta 500 g 4 11
5 Organic crushed tomato 400 g 5 22
6 Aloe vera face cream 50 ml 6 4
7 Rosemary solid shampoo 80 g 7 7
8 Almond body oil 200 ml 8 3
9 Calendula lip balm 15 ml 9 12
10 Concentrated eco laundry detergent 1 L 10 5
11 Loofah scrubber (pack of 3) 11 8
12 Reusable cotton bags (pack of 5) 12 6
13 Soy wax candles (pack of 2) (null) (null)
14 Organic chamomile tea 20 bags 14 15
15 Ceremonial matcha green tea 30 g 15 2
16 Ginger kombucha 750 ml 16 10
17 Cold-pressed orange juice 1 L 17 4
18 Bamboo toothbrush 18 19
19 Natural stick deodorant 50 g (null) (null)
20 Spirulina capsules 120 units (null) (null)
(null) (null) 101 7
(null) (null) 102 3

22 rows = 17 pairings + 3 products with no marketplace sales + 2 unknown SKUs.

Now there really are orphans on both sides, and each one tells a different story:

  • Products 13, 19 and 20 haven't sold on the marketplace. They coincide with the ones that don't sell in our own shop either, which reinforces the diagnosis: no stock, discontinued, and a commercial problem.
  • SKUs 101 and 102 have generated sales on the marketplace but don't exist in the catalogue. That's a serious operational alert: it could be an old reference under a different numbering, a mapping error between systems, or sales nobody is attributing to any product.

A writing detail: the ORDER BY COALESCE(p.id, ms.sku) sorts by the identifier "wherever it comes from". If you sorted only by p.id, the two marketplace rows would have NULL in that column and would go to the end (or the beginning, depending on NULLS FIRST/LAST, as you saw in 02-05). COALESCE returns the first non-null value in the list, and it's studied in depth in 06-04.

  1. The "only what doesn't match on either side" pattern

The complete reconciliation is fine for reviewing, but what gets sent to operations is just the list of discrepancies. It's the equivalent of 03-03's anti-join, now on both counts:

SELECT p.id   AS product_id,
       p.name AS product,
       ms.sku,
       ms.units
FROM products AS p
FULL OUTER JOIN marketplace_sales AS ms ON ms.sku = p.id
WHERE p.id IS NULL
   OR ms.sku IS NULL
ORDER BY COALESCE(p.id, ms.sku);
product_id product sku units
13 Soy wax candles (pack of 2) (null) (null)
19 Natural stick deodorant 50 g (null) (null)
20 Spirulina capsules 120 units (null) (null)
(null) (null) 101 7
(null) (null) 102 3

5 rows: the three catalogue discrepancies and the two from the file. This is a genuine reconciliation report, and in a real system it would land in your inbox every morning.

In set terms, this pattern returns the symmetric difference:

flowchart LR
    subgraph R[" "]
        direction LR
        A(("catalogue only<br/>✅"))
        I(("they match<br/>❌ excluded by the WHERE"))
        B(("marketplace only<br/>✅"))
    end
    style A fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
    style I fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4
    style B fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px

Two important writing details:

Detail Why
The operator is OR, not AND With AND you'd be asking for rows where both keys are missing at once, which is impossible: every row of the result comes from at least one side. AND always returns 0 rows
You compare each side's keys p.id and ms.sku. Just as in the simple anti-join, you have to pick columns that can't legitimately be NULL in their source tables

And the three variants of the filter, depending on what you want:

WHERE Returns Rows here
(none) The complete reconciliation 22
p.id IS NULL OR ms.sku IS NULL Discrepancies only (symmetric difference) 5
ms.sku IS NULL Only what's in the catalogue and not in the file 3
p.id IS NULL Only what's in the file and not in the catalogue 2

  1. Emulation on engines that don't support it

MySQL and MariaDB don't implement FULL OUTER JOIN. The classic emulation consists of combining a LEFT JOIN and a RIGHT JOIN with UNION:

-- Emulating FULL OUTER JOIN in MySQL
SELECT p.id AS product_id, p.name AS product, ms.sku, ms.units
FROM products AS p
LEFT JOIN marketplace_sales AS ms ON ms.sku = p.id

UNION

SELECT p.id, p.name, ms.sku, ms.units
FROM products AS p
RIGHT JOIN marketplace_sales AS ms ON ms.sku = p.id;

The logic is straightforward:

flowchart TD
    A["LEFT JOIN<br/>matches + left orphans<br/>= 20 rows"] --> C["UNION<br/>stacks and removes duplicates"]
    B["RIGHT JOIN<br/>matches + right orphans<br/>= 19 rows"] --> C
    C --> D["22 rows<br/>= FULL OUTER JOIN"]

The 17 matching rows appear in both queries. That's why you have to use UNION and not UNION ALL: UNION removes duplicates and leaves 20 + 19 − 17 = 22 rows. With UNION ALL you'd get 39 rows, with the 17 matches repeated twice.

UNION, UNION ALL and their rules are the subject of lesson 03-07; here it's enough to know that the emulation exists and why it needs to remove duplicates.

Dialect note: in MySQL the emulation has an unpleasant twist. Since UNION compares whole rows, two rows differing in any column are considered distinct; if your data set legitimately contained repeated rows, UNION would collapse them and you'd lose information. The robust alternative is a complete LEFT JOIN UNION ALLed with only the right-hand orphans (RIGHT JOIN ... WHERE p.id IS NULL), which generates no overlap and needs no deduplication.

  1. Support by engine, and cost

Engine FULL OUTER JOIN Note
PostgreSQL ✅ Yes Since very old versions, no restrictions
MySQL / MariaDB No You have to emulate it with a UNION of a LEFT and a RIGHT
SQLite ✅ Yes, since 3.39 (June 2022) Same as RIGHT JOIN
SQL Server ✅ Yes No restrictions
Oracle ✅ Yes The old (+) syntax can't express a full outer

Why it's rarely used

Four reasons, in order of importance:

  1. Referential integrity makes it unnecessary within a single schema. That's section 2's argument, and it explains most cases.
  2. What you want is almost always one of the two halves. "Products with no sales" or "sales with no product" are specific questions that a LEFT JOIN with an anti-join answers better, and with a more readable result.
  3. The result is awkward to read. A table with nulls on both sides forces you into COALESCE for sorting, for grouping and for presenting. Every key column exists twice over.
  4. It's the most expensive JOIN. The engine has to traverse both relations completely and mark the unmatched rows on both sides: it can't stop early or discard soon. In PostgreSQL it's only implemented with a hash join or a merge join; there's no nested loop plan for a FULL OUTER JOIN, which sometimes forces both sides to be materialised and sorted. With large tables you notice it (module 8).

Practical rule: before writing a FULL OUTER JOIN, ask yourself whether you genuinely need the orphans from both sides in the same result. Nine times out of ten the answer is no, and a LEFT JOIN is clearer and faster. The tenth time —a real reconciliation between two systems— is exactly what it exists for.

Common Mistakes and Tips

  • Using FULL OUTER JOIN between two tables joined by a NOT NULL FK. It contributes no row over the LEFT JOIN and costs more. Referential integrity already guarantees there are no orphans on the child side.
  • Writing AND instead of OR in the discrepancy filter. WHERE p.id IS NULL AND ms.sku IS NULL always returns 0 rows: no row of the result can be missing from both sides at once.
  • Sorting by just one of the two keys. The orphan rows from the other side have that column at NULL and go to the far end of the listing. Use ORDER BY COALESCE(a.key, b.key).
  • Forgetting that the key columns are duplicated. In the reconciliation you have p.id and ms.sku. To present a single identifier you need COALESCE(p.id, ms.sku).
  • Emulating it with UNION ALL in MySQL. It duplicates every matching row: 39 instead of 22 in this lesson's example.
  • Assuming it exists in MySQL. It doesn't, and it isn't planned. If your SQL has to be portable, avoid it.
  • Confusing "no partner" with "empty value". Just as in 03-03: always check against a column that can't legitimately be NULL in its source table.
  • Tip: start with the LEFT JOIN and check whether you need more. Run the LEFT, count rows, run the FULL OUTER and compare. If the number doesn't change, the FULL OUTER is redundant.
  • Tip: in a reconciliation, add a column saying where each row comes from. With CASE (module 6) you can label each row as "catalogue only", "file only" or "matches". The report becomes self-explanatory.
  • Tip: keep the discrepancy pattern. FULL OUTER JOIN ... WHERE a.key IS NULL OR b.key IS NULL is a template you'll reuse every time two systems have to reconcile.

Exercises

Exercise 1

Run these two queries and compare the number of rows:

SELECT c.id, c.name, o.id AS order_id
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id;

SELECT c.id, c.name, o.id AS order_id
FROM customers AS c
FULL OUTER JOIN orders AS o ON o.customer_id = c.id;
  1. How many rows does each one return?
  2. Explain the result in terms of the constraints declared on orders.customer_id.
  3. What would have to change in the schema for the two queries to give different results?

Exercise 2

Over the temporary table marketplace_sales from section 3, write two separate queries:

  1. The catalogue products that don't appear in the marketplace file, with their name, price and stock.
  2. The SKUs in the file that don't exist in the catalogue, with the units sold.

Write the first with a LEFT JOIN and the second with a RIGHT JOIN, and then reason it out: why are two separate queries more useful here than section 4's FULL OUTER JOIN?

Exercise 3

Your company is migrating a reconciliation query written in PostgreSQL over to MySQL:

SELECT p.id AS product_id, p.name AS product, ms.sku, ms.units
FROM products AS p
FULL OUTER JOIN marketplace_sales AS ms ON ms.sku = p.id
WHERE p.id IS NULL OR ms.sku IS NULL;
  1. Rewrite it for MySQL using UNION.
  2. Could you use UNION ALL in this particular rewrite? Reason it out by looking at what each half returns.

Solutions

Solution 1

1. Both return 23 rows.

2. The column is declared like this:

customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE RESTRICT

The three parts act together:

  • REFERENCES customers(id) prevents inserting an order with a non-existent customer_id.
  • NOT NULL prevents inserting an order with no customer.
  • ON DELETE RESTRICT prevents deleting a customer who has orders, which is the other way of generating orphans.

Conclusion: no orphan order can exist, so the right side contributes nothing and the FULL OUTER JOIN degenerates into the LEFT JOIN. The 23 rows are the same ones: 20 orders + 3 customers with no orders.

3. It would be enough for customer_id to allow NULL —for example, to record guest orders with no account. Those orders would match no customer and would appear as orphans on the right, visible only with a FULL OUTER JOIN (or with a RIGHT JOIN). It's exactly the situation of orders.employee_id, which does allow nulls: orders FULL OUTER JOIN employees returns 25 rows against the LEFT JOIN's 20.

Solution 2

1. Catalogue products that aren't in the file:

SELECT p.id,
       p.name AS product,
       p.price,
       p.stock
FROM products AS p
LEFT JOIN marketplace_sales AS ms ON ms.sku = p.id
WHERE ms.sku IS NULL
ORDER BY p.id;
id product price stock
13 Soy wax candles (pack of 2) 13.75 0
19 Natural stick deodorant 50 g 7.80 75
20 Spirulina capsules 120 units 16.40 55

2. SKUs in the file that don't exist in the catalogue:

SELECT ms.sku,
       ms.units
FROM products AS p
RIGHT JOIN marketplace_sales AS ms ON ms.sku = p.id
WHERE p.id IS NULL
ORDER BY ms.sku;
sku units
101 7
102 3

Why two separate queries are more useful here:

Reason Explanation
Different columns A product with no sales is interesting with its price and stock; an unknown SKU is interesting with the units sold. In a single table you'd have to return the union of both sets of columns, with half of them NULL on every row
Different audiences The first list goes to marketing; the second, to the integrations team. They're two incidents with two owners
Different urgency A product with no sales is a commercial observation; a sold SKU that doesn't exist in the catalogue is a data error to fix today
Readability Each query has a single meaning and explains itself. The FULL OUTER forces you to read the nulls to work out what kind of row each one is

The FULL OUTER JOIN is still useful for the overall picture —section 3's 22-row table, for reviewing at a glance— but the operational work is better handled with two targeted queries.

Solution 3

1. Rewrite for MySQL:

-- Left half: catalogue products with no SKU in the file
SELECT p.id AS product_id, p.name AS product, ms.sku, ms.units
FROM products AS p
LEFT JOIN marketplace_sales AS ms ON ms.sku = p.id
WHERE ms.sku IS NULL

UNION

-- Right half: file SKUs with no product in the catalogue
SELECT p.id, p.name, ms.sku, ms.units
FROM products AS p
RIGHT JOIN marketplace_sales AS ms ON ms.sku = p.id
WHERE p.id IS NULL;

It returns the same 5 rows as the PostgreSQL version.

2. Can you use UNION ALL? Yes, and it's preferable.

The reasoning is what matters: UNION (without ALL) is needed when the two halves can produce the same rows, and the duplicates have to be removed. That's the case with section 5's general emulation, where both JOINs return the 17 matching rows.

That doesn't happen here. Each half carries its own WHERE restricting it to the orphans of its side:

Half Filter Returns Overlap
Left ms.sku IS NULL 3 rows: products with no SKU
Right p.id IS NULL 2 rows: SKUs with no product none

No row can satisfy both conditions at once, so the sets are disjoint and there's nothing to deduplicate. UNION ALL is faster because it saves the sorting or hash-table step UNION needs to detect duplicates. It's a perfect example of the rule you'll see in 03-07: use UNION ALL unless you have a specific reason to remove duplicates.

Conclusion

You've closed the outer-join family:

  • The FULL OUTER JOIN keeps the orphan rows from both sides: it's a LEFT and a RIGHT at once. It's the only commutative outer join.
  • Over GreenStore it almost never contributes anything, and you can explain why: referential integrity (REFERENCES + NOT NULL + ON DELETE RESTRICT) prevents orphans on the child side from existing. products FULL OUTER JOIN order_lines returns the same 50 rows as the LEFT JOIN.
  • Its real ground is the reconciliation of two independent sources, where neither has authority over the other: a catalogue against a marketplace file, an inventory against accounting, a bank statement against invoices.
  • You've built that complete reconciliation (22 rows) and the discrepancy report with the pattern WHERE a.key IS NULL OR b.key IS NULL (5 rows): three products the marketplace doesn't sell and two SKUs that don't exist in the catalogue. Remember: the operator is OR, never AND.
  • You know how to emulate it with a UNION of a LEFT JOIN and a RIGHT JOIN for MySQL, and why that emulation needs to remove duplicates unless each half is restricted to its own orphans.
  • And you know its cost: it admits no nested loop plan, it forces both relations to be traversed in full and it produces a result that needs COALESCE for nearly everything. Use it when you genuinely need both sides.

In the next lesson, SELF JOIN and CROSS JOIN, we'll look at the two "odd" JOINs that confuse beginners the most. The SELF JOIN will finally let you walk GreenStore's two reflexive relationships: the employees hierarchy with manager_id —where you'll need a LEFT JOIN again so as not to lose Rosa, the manager with no manager— and the customers referral network. The CROSS JOIN, which so far has only shown up as an accident, will become a deliberate tool for generating complete combinations.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved