AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

09 Set operations and CTEs / Lesson 2 of 4

INTERSECT and EXCEPT — rows in both, rows in one but not the other

14 min · +10 XP · 2 exercises · 5 questions

Read the text14 min
Exercises0/2
Quiz5 q

Quick answer

What do INTERSECT and EXCEPT do in SQL?

INTERSECT returns the distinct rows that appear in the results of both queries; EXCEPT returns the distinct rows of the first query that do not appear in the second. Like UNION, they match columns by position and compare whole rows, treating NULLs as equal. MySQL supports both from version 8.0.31; on older versions write them with EXISTS, NOT EXISTS or a join.

UNION answers "rows from either query". Two more set operations finish the family: INTERSECT answers "rows in *both*", and EXCEPT answers "rows in the first but not the second". Students enrolled in both SQL and Python; products in the catalogue that never sold; customers who ordered last year but not this year — each is one of these two operations. MySQL only gained them in version 8.0.31, so production code and interview answers are full of the older ways to write the same thing: EXISTS, NOT EXISTS and joins. This lesson teaches both forms, and why one popular form, NOT IN, is a trap.

INTERSECT

SELECT student FROM enrollments WHERE course = 'SQL'
INTERSECT
SELECT student FROM enrollments WHERE course = 'Python';

Each SELECT runs on its own; the result is every row that appears in both results, once. The rules are the union rules: the same number of columns, compatible types by position, names from the first branch, one ORDER BY at the end that sorts the final result.

INTERSECT: the rows both results contain, once each
SELECT student … 'SQL' INTERSECT SELECT student … 'Python'

Each SELECT runs on its own: the students taking SQL and the students taking Python. Asha is enrolled in SQL twice.

0:01 / 0:08Step 1 of 3
Transcript · 3 steps

INTERSECT compares whole rows. Intersecting (student, city) pairs requires the same student in the same city in both results — not just the same student. And like UNION, it removes duplicates: a student enrolled in SQL twice still appears once. The standard also defines INTERSECT ALL (keep min(m, n) copies of a row appearing m and n times), which MySQL 8.0.31+ and PostgreSQL support; it is rarely needed.

EXCEPT

SELECT sku FROM products
EXCEPT
SELECT sku FROM sales;

Every distinct row of the first result that does not appear in the second: the products that never sold. Order matters — sales EXCEPT products would be "SKUs sold that are not in the catalogue", a data-quality check rather than a business question. Oracle spells it MINUS.

Both operations treat NULLs as equal when comparing rows, exactly like DISTINCT. That makes them safer than you might expect, as the NOT IN section will show.

Precedence and mixing

When one statement mixes the operators, INTERSECT binds tighter than UNION and EXCEPT in the SQL standard, MySQL, PostgreSQL and SQL Server:

SELECT id FROM a
UNION
SELECT id FROM b
INTERSECT
SELECT id FROM c;          -- a UNION (b INTERSECT c)

SQLite reads the same statement left to right. Rather than remember, make the grouping explicit with parentheses (in MySQL) or with a CTE per step (next lesson).

The emulation: EXISTS and NOT EXISTS

Before 8.0.31 — and in a great deal of code still running — the two operations are written as semi-joins and anti-joins. The intersection:

SELECT DISTINCT e.student
FROM enrollments e
WHERE e.course = 'SQL'
  AND EXISTS (SELECT 1
              FROM enrollments p
              WHERE p.student = e.student
                AND p.course = 'Python');

And the difference:

SELECT p.sku
FROM products p
WHERE NOT EXISTS (SELECT 1 FROM sales s WHERE s.sku = p.sku);

The correlated subquery asks, per outer row, "is there a matching row over there?" EXISTS keeps the row when the answer is yes, NOT EXISTS when it is no. The optimizer turns both into a semi-join or anti-join that stops at the first match. Two details matter:

  • Duplicates. INTERSECT and EXCEPT return distinct rows; the EXISTS forms return the outer table's rows as they are. Add DISTINCT if the outer side can repeat (a student enrolled in SQL twice).
  • Multi-column rows. Compare every column in the correlation: WHERE s.sku = p.sku AND s.store = p.store. A NULL in a compared column never matches with = — where EXCEPT would call two NULL rows equal, NOT EXISTS keeps the row. Usually that difference does not arise, because keys are not NULL.

The same anti-join is often written as a LEFT JOIN that finds no partner:

SELECT p.sku
FROM products p
LEFT JOIN sales s ON s.sku = p.sku
WHERE s.sku IS NULL;

The LEFT JOIN keeps every product and fills the sales columns with NULL when no sale matches; WHERE s.sku IS NULL keeps exactly those. It returns one row per product only because unmatched products produce a single NULL-extended row — matched products produce one row per sale and are then all filtered out. Test the join column (or a NOT NULL column) of the right table for NULL, never a nullable column such as an amount.

An intersection can also be written as an inner join — SELECT DISTINCT a.student FROM enrollments a JOIN enrollments b ON b.student = a.student WHERE a.course = 'SQL' AND b.course = 'Python' — or with GROUP BY student HAVING COUNT(DISTINCT course) = 2 over WHERE course IN ('SQL', 'Python'). The last form generalises nicely: "enrolled in all of these five courses" is = 5.

The NOT IN trap

The tempting third way to write "never sold":

SELECT sku FROM products
WHERE sku NOT IN (SELECT sku FROM sales);

It works — until one row in sales has sku NULL. Then it returns nothing. x NOT IN (a, b, NULL) expands to x <> a AND x <> b AND x <> NULL; the last comparison is unknown, so the conjunction is never true, so no row passes. No error, no warning, an empty result that looks plausible on a busy day.

Products never sold: EXCEPT, NOT EXISTS and NOT IN
products A1, B2, C3, D4 — and one sale with a NULL sku

Four products and three sales, one of them with a NULL sku. The question: which products never sold? The answer should be C3 and D4.

0:01 / 0:14Step 1 of 4
Transcript · 4 steps

Defences, in order of preference: use NOT EXISTS or EXCEPT; or filter the subquery (WHERE sku IS NOT NULL); or make the column NOT NULL in the schema so the case cannot arise. Interviewers ask this exact question, and "NOT IN with a NULL returns no rows" is the answer they are waiting for. (IN is not affected the same way: x IN (a, NULL) is true when x = a, and merely unknown otherwise, which filters the same as false.)

Choosing a form

NeedModernPortable
rows in bothINTERSECTEXISTS, inner join + DISTINCT
rows in A not BEXCEPTNOT EXISTS, LEFT JOIN … IS NULL
whole-row comparison with NULLs equalINTERSECT / EXCEPTNOT EXISTS with <=> per column

INTERSECT and EXCEPT read best when both sides are naturally "a list of the same kind of thing". When you need columns from the outer table that the other side does not have — the product's name, not just its SKU — the EXISTS forms are simpler, because a set operation can only return columns both sides have. You can also combine them: compute the SKUs with EXCEPT in a CTE, then join back to products for the names.

On performance, modern optimizers usually turn NOT EXISTS and LEFT JOIN … IS NULL into the same anti-join plan; EXCEPT may instead build a temporary table of the second result. Measure on real data when it matters (the indexes module shows how).

In other databases

PostgreSQL, SQL Server and SQLite have had INTERSECT and EXCEPT for years; Oracle uses MINUS (and accepts EXCEPT from 21c). SQL Server's EXCEPT and INTERSECT have no ALL form; PostgreSQL's do. The NOT IN null trap is identical in every one of them, because it comes from three-valued logic, not from any product.

Pitfalls

  • NOT IN against a subquery whose column can be NULL.
  • Swapping the branches of EXCEPT.
  • Forgetting that the EXISTS forms keep duplicates the set operations would remove.
  • Testing a nullable column for NULL after a LEFT JOIN anti-join.
  • Mixing operators without parentheses and relying on precedence.

Key takeaways

  • INTERSECT keeps distinct rows present in both results; EXCEPT keeps distinct rows of the first missing from the second.
  • MySQL has both from 8.0.31; EXISTS, NOT EXISTS and LEFT JOIN … IS NULL express them on any version.
  • Set operations compare whole rows by position and treat NULLs as equal.
  • NOT IN returns no rows if the list contains a NULL — prefer NOT EXISTS or EXCEPT.
  • Use a set operation for "same kind of list"; use EXISTS when you need the outer table's other columns.

Common questions

Does MySQL support INTERSECT and EXCEPT?

Yes, since MySQL 8.0.31, released in October 2022. Earlier MySQL versions and MariaDB before 10.3 reject them, which is why many answers and legacy code use EXISTS/NOT EXISTS or IN/LEFT JOIN … IS NULL instead. Those forms work on every version and every database.

What is the difference between EXCEPT and MINUS?

None in meaning: Oracle calls the operation MINUS, while the SQL standard, PostgreSQL, SQL Server, SQLite and MySQL 8.0.31+ call it EXCEPT. Oracle 21c accepts EXCEPT as well. Both return the distinct rows of the first query missing from the second.

Why does NOT IN return no rows when the subquery has a NULL?

x NOT IN (1, NULL) means x <> 1 AND x <> NULL, and x <> NULL is unknown, so the whole condition is never true and every row is filtered out. NOT EXISTS and EXCEPT do not have this problem, which is why they are the safe ways to write "in A but not in B".

Is the order of the queries important in EXCEPT?

Yes. A EXCEPT B is the rows of A missing from B, while B EXCEPT A is the rows of B missing from A, usually a different answer. INTERSECT and UNION are symmetric, so swapping their branches changes only the column names, which come from the first query.

Which runs first, INTERSECT or UNION?

INTERSECT binds tighter than UNION and EXCEPT in the SQL standard and in MySQL, PostgreSQL and SQL Server, so A UNION B INTERSECT C means A UNION (B INTERSECT C). SQLite evaluates them left to right instead. Use parentheses or a CTE whenever you mix them.

Mark as readSign in to keep your place.

SQL INTERSECT and EXCEPT in MySQL 8 with NOT EXISTS

INTERSECT keeps rows both queries return, EXCEPT keeps rows of the first that the second lacks. MySQL 8.0.31 syntax, NOT EXISTS emulation and the NOT IN trap.

  • Course: SQL study plan
  • Module: Set operations and CTEs
  • Kind: Lesson
  • Reading time: 14 min
  • Runtime: MySQL 8 syntax on SQLite 3.49

What do INTERSECT and EXCEPT do in SQL?

INTERSECT returns the distinct rows that appear in the results of both queries; EXCEPT returns the distinct rows of the first query that do not appear in the second. Like UNION, they match columns by position and compare whole rows, treating NULLs as equal. MySQL supports both from version 8.0.31; on older versions write them with EXISTS, NOT EXISTS or a join.

Lesson

UNION answers "rows from either query". Two more set operations finish the family: INTERSECT answers "rows in *both*", and EXCEPT answers "rows in the first but not the second". Students enrolled in both SQL and Python; products in the catalogue that never sold; customers who ordered last year but not this year — each is one of these two operations. MySQL only gained them in version 8.0.31, so production code and interview answers are full of the older ways to write the same thing: EXISTS, NOT EXISTS and joins. This lesson teaches both forms, and why one popular form, NOT IN, is a trap.

INTERSECT

SELECT student FROM enrollments WHERE course = 'SQL'
INTERSECT
SELECT student FROM enrollments WHERE course = 'Python';

Each SELECT runs on its own; the result is every row that appears in both results, once. The rules are the union rules: the same number of columns, compatible types by position, names from the first branch, one ORDER BY at the end that sorts the final result.

WHERE course = 'SQL'… = 'Python'AshaBenKiranAshaKiranAshaDevINTERSECTAshaKiran
INTERSECT: the rows both results contain, once each. Example: SELECT student … 'SQL' INTERSECT SELECT student … 'Python'
  1. Each SELECT runs on its own: the students taking SQL and the students taking Python. Asha is enrolled in SQL twice.
  2. INTERSECT compares whole rows across the two results. Asha and Kiran appear in both; Ben and Dev appear in only one.
  3. The result is each shared row once — Asha, Kiran — Asha's two SQL rows included. Like UNION, INTERSECT removes duplicates.

INTERSECT compares whole rows. Intersecting (student, city) pairs requires the same student in the same city in both results — not just the same student. And like UNION, it removes duplicates: a student enrolled in SQL twice still appears once. The standard also defines INTERSECT ALL (keep min(m, n) copies of a row appearing m and n times), which MySQL 8.0.31+ and PostgreSQL support; it is rarely needed.

EXCEPT

SELECT sku FROM products
EXCEPT
SELECT sku FROM sales;

Every distinct row of the first result that does not appear in the second: the products that never sold. Order matters — sales EXCEPT products would be "SKUs sold that are not in the catalogue", a data-quality check rather than a business question. Oracle spells it MINUS.

Both operations treat NULLs as equal when comparing rows, exactly like DISTINCT. That makes them safer than you might expect, as the NOT IN section will show.

Precedence and mixing

When one statement mixes the operators, INTERSECT binds tighter than UNION and EXCEPT in the SQL standard, MySQL, PostgreSQL and SQL Server:

SELECT id FROM a
UNION
SELECT id FROM b
INTERSECT
SELECT id FROM c;          -- a UNION (b INTERSECT c)

SQLite reads the same statement left to right. Rather than remember, make the grouping explicit with parentheses (in MySQL) or with a CTE per step (next lesson).

The emulation: EXISTS and NOT EXISTS

Before 8.0.31 — and in a great deal of code still running — the two operations are written as semi-joins and anti-joins. The intersection:

SELECT DISTINCT e.student
FROM enrollments e
WHERE e.course = 'SQL'
  AND EXISTS (SELECT 1
              FROM enrollments p
              WHERE p.student = e.student
                AND p.course = 'Python');

And the difference:

SELECT p.sku
FROM products p
WHERE NOT EXISTS (SELECT 1 FROM sales s WHERE s.sku = p.sku);

The correlated subquery asks, per outer row, "is there a matching row over there?" EXISTS keeps the row when the answer is yes, NOT EXISTS when it is no. The optimizer turns both into a semi-join or anti-join that stops at the first match. Two details matter:

  • Duplicates. INTERSECT and EXCEPT return distinct rows; the EXISTS forms return the outer table's rows as they are. Add DISTINCT if the outer side can repeat (a student enrolled in SQL twice).
  • Multi-column rows. Compare every column in the correlation: WHERE s.sku = p.sku AND s.store = p.store. A NULL in a compared column never matches with = — where EXCEPT would call two NULL rows equal, NOT EXISTS keeps the row. Usually that difference does not arise, because keys are not NULL.

The same anti-join is often written as a LEFT JOIN that finds no partner:

SELECT p.sku
FROM products p
LEFT JOIN sales s ON s.sku = p.sku
WHERE s.sku IS NULL;

The LEFT JOIN keeps every product and fills the sales columns with NULL when no sale matches; WHERE s.sku IS NULL keeps exactly those. It returns one row per product only because unmatched products produce a single NULL-extended row — matched products produce one row per sale and are then all filtered out. Test the join column (or a NOT NULL column) of the right table for NULL, never a nullable column such as an amount.

An intersection can also be written as an inner join — SELECT DISTINCT a.student FROM enrollments a JOIN enrollments b ON b.student = a.student WHERE a.course = 'SQL' AND b.course = 'Python' — or with GROUP BY student HAVING COUNT(DISTINCT course) = 2 over WHERE course IN ('SQL', 'Python'). The last form generalises nicely: "enrolled in all of these five courses" is = 5.

The NOT IN trap

The tempting third way to write "never sold":

SELECT sku FROM products
WHERE sku NOT IN (SELECT sku FROM sales);

It works — until one row in sales has sku NULL. Then it returns nothing. x NOT IN (a, b, NULL) expands to x <> a AND x <> b AND x <> NULL; the last comparison is unknown, so the conjunction is never true, so no row passes. No error, no warning, an empty result that looks plausible on a busy day.

sales.sku: 'A1', 'B2', NULLproducts.skuA1B2C3D4EXCEPTsold: dropsold: dropkeepkeep→ C3, D4NOT EXISTSsold: dropsold: dropkeepkeep→ C3, D4NOT INFALSE: dropFALSE: dropUNKNOWN: dropUNKNOWN: drop→ (no rows)
Products never sold: EXCEPT, NOT EXISTS and NOT IN. Example: products A1, B2, C3, D4 — and one sale with a NULL sku
  1. Four products and three sales, one of them with a NULL sku. The question: which products never sold? The answer should be C3 and D4.
  2. EXCEPT removes every product row that also appears in sales. It compares whole rows with NULLs treated as equal, so the NULL sale is just a row matching nothing here: C3 and D4 remain.
  3. NOT EXISTS asks, per product, whether some sale has s.sku = p.sku. For C3 the NULL sale gives UNKNOWN, which is not a match, so no matching row exists: C3 and D4 again.
  4. NOT IN expands to C3 <> 'A1' AND C3 <> 'B2' AND C3 <> NULL. The last test is UNKNOWN, so the AND never becomes TRUE and the query returns no rows at all.

Defences, in order of preference: use NOT EXISTS or EXCEPT; or filter the subquery (WHERE sku IS NOT NULL); or make the column NOT NULL in the schema so the case cannot arise. Interviewers ask this exact question, and "NOT IN with a NULL returns no rows" is the answer they are waiting for. (IN is not affected the same way: x IN (a, NULL) is true when x = a, and merely unknown otherwise, which filters the same as false.)

Choosing a form

NeedModernPortable
rows in bothINTERSECTEXISTS, inner join + DISTINCT
rows in A not BEXCEPTNOT EXISTS, LEFT JOIN … IS NULL
whole-row comparison with NULLs equalINTERSECT / EXCEPTNOT EXISTS with <=> per column

INTERSECT and EXCEPT read best when both sides are naturally "a list of the same kind of thing". When you need columns from the outer table that the other side does not have — the product's name, not just its SKU — the EXISTS forms are simpler, because a set operation can only return columns both sides have. You can also combine them: compute the SKUs with EXCEPT in a CTE, then join back to products for the names.

On performance, modern optimizers usually turn NOT EXISTS and LEFT JOIN … IS NULL into the same anti-join plan; EXCEPT may instead build a temporary table of the second result. Measure on real data when it matters (the indexes module shows how).

In other databases

PostgreSQL, SQL Server and SQLite have had INTERSECT and EXCEPT for years; Oracle uses MINUS (and accepts EXCEPT from 21c). SQL Server's EXCEPT and INTERSECT have no ALL form; PostgreSQL's do. The NOT IN null trap is identical in every one of them, because it comes from three-valued logic, not from any product.

Pitfalls

  • NOT IN against a subquery whose column can be NULL.
  • Swapping the branches of EXCEPT.
  • Forgetting that the EXISTS forms keep duplicates the set operations would remove.
  • Testing a nullable column for NULL after a LEFT JOIN anti-join.
  • Mixing operators without parentheses and relying on precedence.

Key takeaways

  • INTERSECT keeps distinct rows present in both results; EXCEPT keeps distinct rows of the first missing from the second.
  • MySQL has both from 8.0.31; EXISTS, NOT EXISTS and LEFT JOIN … IS NULL express them on any version.
  • Set operations compare whole rows by position and treat NULLs as equal.
  • NOT IN returns no rows if the list contains a NULL — prefer NOT EXISTS or EXCEPT.
  • Use a set operation for "same kind of list"; use EXISTS when you need the outer table's other columns.

Common questions

Does MySQL support INTERSECT and EXCEPT?

Yes, since MySQL 8.0.31, released in October 2022. Earlier MySQL versions and MariaDB before 10.3 reject them, which is why many answers and legacy code use EXISTS/NOT EXISTS or IN/LEFT JOIN … IS NULL instead. Those forms work on every version and every database.

What is the difference between EXCEPT and MINUS?

None in meaning: Oracle calls the operation MINUS, while the SQL standard, PostgreSQL, SQL Server, SQLite and MySQL 8.0.31+ call it EXCEPT. Oracle 21c accepts EXCEPT as well. Both return the distinct rows of the first query missing from the second.

Why does NOT IN return no rows when the subquery has a NULL?

x NOT IN (1, NULL) means x <> 1 AND x <> NULL, and x <> NULL is unknown, so the whole condition is never true and every row is filtered out. NOT EXISTS and EXCEPT do not have this problem, which is why they are the safe ways to write "in A but not in B".

Is the order of the queries important in EXCEPT?

Yes. A EXCEPT B is the rows of A missing from B, while B EXCEPT A is the rows of B missing from A, usually a different answer. INTERSECT and UNION are symmetric, so swapping their branches changes only the column names, which come from the first query.

Which runs first, INTERSECT or UNION?

INTERSECT binds tighter than UNION and EXCEPT in the SQL standard and in MySQL, PostgreSQL and SQL Server, so A UNION B INTERSECT C means A UNION (B INTERSECT C). SQLite evaluates them left to right instead. Use parentheses or a CTE whenever you mix them.

Exercises

Students in both courses

Find the students enrolled in both 'SQL' and 'Python'. A student may appear more than once for the same course.

enrollments: studentcourse
AshaSQL
AshaPython
BenSQL
KiranPython
KiranSQL
KiranSQL
MeeraJava
MeeraPython

Return one column, student, each student once, ordered by student.

student
Asha
Kiran

INTERSECT, EXISTS and GROUP BY … HAVING all work — try more than one.

Products that never sold

List the products with no row in sales. The sales.sku column is nullable — a till sometimes records a sale without a SKU.

products: skunameprice
A1Kettle1499.00
B2Toaster1999.00
C3Blender2499.00
D4Mixer3299.00
sales: idskuqty
1A12
2B21
3A11

Return sku and name, ordered by sku.

sku | name
C3 | Blender
D4 | Mixer

Make sure your query still works when sales contains a row whose sku is NULL.

In this module: Set operations and CTEs

← UNION and UNION ALL — stacking result sets · Common table expressions — naming the steps of a query →