Set Operations: UNION, INTERSECT, EXCEPT

Learn SQL /Subqueries, CTEs & Set Operations /Lesson 7 of 7 /Advanced /SQLite in your browser
In this lesson you’ll learn to
  • Stack results with UNION and UNION ALL
  • Intersect and subtract result sets

Every query you have written returns one result table. Sometimes a report needs two lists in one: the shop’s “needs attention” page should show products that are out of stock and products that nobody has ever ordered, stacked into a single result. Joins cannot do that. A join glues tables side by side, adding columns. Set operations stack query results on top of each other, adding rows. This closing lesson of the module gives you all three: UNION, INTERSECT, and EXCEPT, plus the payoff of a promise from Module 3.

This builds on Recursive CTEs, where you generated a July calendar with WITH RECURSIVE, gluing the anchor to the recursive step with UNION ALL.

UNION and UNION ALL: Stacking Two Results

Start with the two lists themselves. Out-of-stock products is a Module 1 query: WHERE stock = 0 finds the USB-C Hub and the Wool Beanie. Never-ordered products is the LEFT JOIN pattern from lesson 3.2: only the Wool Beanie has no order_items row. Notice the overlap: the Wool Beanie is on both lists. That overlap is exactly what makes the next choice matter.

UNION stacks the second query’s rows under the first query’s rows, then removes duplicate rows. Predict what that means here:

The first SELECT returns 2 rows, the second returns 1. How many rows does the UNION return?

SELECT name FROM products WHERE stock = 0
UNION
SELECT p.name
FROM products AS p
LEFT JOIN order_items AS oi ON oi.product_id = p.id
WHERE oi.id IS NULL;
  • 3
  • 2
  • 1

2 + 1 = 3 rows go in, but UNION deduplicates the combined result, and the Wool Beanie appears in both source queries. One copy survives, so you get 2 rows: USB-C Hub and Wool Beanie. If you wanted all 3 rows kept, you needed UNION ALL.

That silent deduplication surprises almost everyone. If you want to keep every row, duplicates included, write UNION ALL. Click between the two:

SELECT name FROM products WHERE stock = 0
UNION
SELECT p.name
FROM products AS p
LEFT JOIN order_items AS oi ON oi.product_id = p.id
WHERE oi.id IS NULL
ORDER BY name;
name
USB-C Hub
Wool Beanie
SELECT name FROM products WHERE stock = 0
UNION ALL
SELECT p.name
FROM products AS p
LEFT JOIN order_items AS oi ON oi.product_id = p.id
WHERE oi.id IS NULL
ORDER BY name;
name
USB-C Hub
Wool Beanie
Wool Beanie
UNION

Stacks both results, then removes duplicate rows. The engine has to compare every row against every other, which costs time on large results. Use it when duplicates would be wrong.

UNION ALL

Stacks both results and stops. Nothing is compared, nothing is removed, so it is cheaper. When your two queries cannot overlap, or you want the duplicates, this is the one you want.

Treat UNION ALL as your default and upgrade to UNION only when you actually need the dedupe. You have already been using UNION ALL this way: the recursive CTEs of lesson 5.6 use it precisely because every generated row should be kept.

One Labeled List, and the Rules

A raw list of names does not tell the shop manager why each product needs attention. The fix is a label column: each branch selects a constant string naming its source. This is the everyday, practical use of UNION ALL, one combined report from two different questions.

Before you run it, look at the ORDER BY on the last line. Which of the two SELECTs does it belong to?

SELECT name, 'out of stock' AS source
FROM products
WHERE stock = 0
UNION ALL
SELECT p.name, 'never ordered' AS source
FROM products AS p
LEFT JOIN order_items AS oi ON oi.product_id = p.id
WHERE oi.id IS NULL
ORDER BY name, source;
name | source
USB-C Hub | out of stock
Wool Beanie | never ordered
Wool Beanie | out of stock

Neither. The ORDER BY belongs to the combined result: it always goes after the last branch and sorts the whole stack, which is why the two Wool Beanie rows sit together. You cannot give each branch its own ORDER BY; sorting one half of a stack would be meaningless, and SQLite rejects the syntax. The same goes for LIMIT: last, applied to the whole result.

Notice something else: the Wool Beanie appears twice here, and it would still appear twice if you swapped UNION ALL for UNION. Deduplication compares entire rows, and ('Wool Beanie', 'out of stock') is not the same row as ('Wool Beanie', 'never ordered'). Adding a label column quietly disables the dedupe you might have been counting on.

Two more rules hold for every set operation. First, each branch must return the same number of columns, and the values stacked into each column should be the same kind of thing (engines like PostgreSQL enforce type compatibility strictly). Second, the result’s column names come from the first SELECT, which is why the alias AS source on the first branch names the column for the whole result. What happens when the two branches disagree on how many columns they return?

The first SELECT returns two columns, the second returns one. What happens?

SELECT name, price FROM products WHERE stock = 0
UNION
SELECT name FROM products WHERE price > 200;
  • 3 rows, with NULL filling the missing price
  • An error: the SELECTs have different numbers of result columns
  • The extra column is silently dropped

There is no way to stack a two-column row onto a one-column row, so every engine refuses to run the query. SQLite’s message says the SELECTs “do not have the same number of result columns”. NULL-filling is a job for FULL JOIN, not UNION.

INTERSECT and EXCEPT

UNION answers “in either list”. Its two siblings answer the other set questions: INTERSECT keeps only rows that appear in both results, and EXCEPT keeps rows from the first result that do not appear in the second. Both deduplicate, like UNION. Order matters for EXCEPT: A EXCEPT B and B EXCEPT A are different questions.

SELECT name FROM products WHERE stock = 0
INTERSECT
SELECT p.name
FROM products AS p
LEFT JOIN order_items AS oi ON oi.product_id = p.id
WHERE oi.id IS NULL;
name
Wool Beanie
SELECT c.country
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
WHERE o.status = 'pending'
INTERSECT
SELECT c.country
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
WHERE o.status = 'delivered';
country
Ireland
SELECT name FROM products
EXCEPT
SELECT p.name
FROM products AS p
JOIN order_items AS oi ON oi.product_id = p.id;
name
Wool Beanie

The first chip finds the product that is both out of stock and never ordered: the overlap UNION was deduplicating, made visible. The second asks which countries have both a pending and a delivered order, and only Ireland qualifies. The third is an old friend: all products EXCEPT the ordered ones leaves the Wool Beanie. You now have three ways to find it: the LEFT JOIN with IS NULL from 3.2, NOT EXISTS from 5.3, and EXCEPT. All correct; the join and NOT EXISTS forms let you select extra columns from the outer row, while EXCEPT compares whole rows only.

Paying Off the Module 3 Promise: FULL JOIN in MySQL

In lesson 3.6 you learned that MySQL has no FULL JOIN, and that MySQL users emulate it “with UNION, an operator you will meet in Module 5”. You have now met it, so here is the shape. This one is for reading, not running (our SQLite has a real FULL JOIN, and this is MySQL syntax):

-- MySQL: emulate FULL JOIN with LEFT + RIGHT + UNION
SELECT c.name, o.id AS order_id
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
UNION
SELECT c.name, o.id AS order_id
FROM customers AS c
RIGHT JOIN orders AS o ON o.customer_id = c.id;

The LEFT JOIN contributes every matched pair plus the unmatched left-side rows. The RIGHT JOIN contributes every matched pair plus the unmatched right-side rows. Stack them and the matched pairs are in there twice, which is exactly the problem UNION’s deduplication solves: one copy of each matched pair survives, and the unmatched rows from both sides come along. The result is a FULL JOIN built from parts you already own.

In the MySQL emulation above, why must it be UNION rather than the cheaper UNION ALL?

  • UNION ALL is not allowed between two joins
  • Every matched pair appears in both branches, and UNION removes that double-counting; UNION ALL would keep both copies
  • UNION ALL cannot handle the NULLs in unmatched rows

Both branches produce the matched pairs, so the overlap must be deduplicated, and that is UNION’s whole job. UNION ALL is perfectly legal here and handles NULLs fine; it would just return every matched pair twice. This is the rare case where the dedupe is not a surprise but the entire point.

SQLite vs MySQL/PostgreSQL. UNION and UNION ALL work everywhere. INTERSECT and EXCEPT are standard in SQLite and PostgreSQL, but MySQL only added them in version 8.0.31 (2022). On older MySQL you fall back to the Module 5 patterns you already know: EXISTS for an intersect, NOT EXISTS for an except. One naming note for the road: SQL Server uses the same three keywords; Oracle spells EXCEPT as MINUS.

Practice: Countries Still Waiting

The shop manager wants to know which markets have never had a completed sale.

Using EXCEPT, list every country that has a customer but has no delivered order. Take all customer countries, subtract the countries of customers with a delivered order (join customers to orders and filter on status), and sort the result alphabetically by country. Then hit Check my answer.

SELECT country FROM customers
EXCEPT
SELECT c.country
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
WHERE o.status = 'delivered'
ORDER BY country;

What You Learned

  • Set operations stack query results vertically: UNION keeps rows from either result, INTERSECT keeps rows in both, EXCEPT keeps rows in the first but not the second.
  • UNION silently deduplicates whole rows; UNION ALL keeps everything and is cheaper. Default to UNION ALL unless you need the dedupe.
  • The rules: every branch needs the same number of columns with compatible values, column names come from the first SELECT, and ORDER BY (and LIMIT) go after the last branch and apply to the combined result.
  • MySQL emulates FULL JOIN as LEFT JOIN UNION RIGHT JOIN, where the dedupe removes the double-counted matched pairs.
  • Dialect check: INTERSECT and EXCEPT reached MySQL only in 8.0.31 (2022); UNION is universal; SQLite and PostgreSQL have all three.

You can now:

  • Combine two queries into one labeled report with UNION ALL and a source column.
  • Choose between UNION and UNION ALL deliberately, and predict the row-count difference.
  • Answer “in both” and “in one but not the other” questions with INTERSECT and EXCEPT.

That closes Module 5, and with it the entire reading half of SQL: you can filter, join, aggregate, nest, name, generate, and now combine any result the database can produce. Module 6 changes gears: INSERT, UPDATE, and DELETE, and how to change data without destroying it.

You’ll learn to
  • Stack results with UNION and UNION ALL
  • Intersect and subtract result sets
Next: Inserting Rows →