When set operators are more readable than equivalent JOIN or NOT EXISTS constructs
UNION and UNION ALL belong to the standard toolkit, but the two other set operators in the SQL standard, INTERSECT and EXCEPT, lead a shadow existence in many codebases, even though for certain questions they are the clearest and most maintainable solution available. INTERSECT answers which rows appear in two result sets at the same time, EXCEPT answers which rows appear in the first set but not the second. Both questions can also be solved with JOIN or NOT EXISTS constructs, but the set operator often makes the actual business intent visible at a glance. This article covers when switching is worth it, how syntax differs between databases, and how performance actually compares to the alternatives.
Table of Contents
- 1. Basic principle: set comparison instead of row combination
- 2. INTERSECT in practice: customers who ordered in two periods
- 3. EXCEPT in practice: products with no sales
- 4. The equivalent JOIN and NOT EXISTS variants compared
- 5. Performance comparison: set operator vs. JOIN vs. NOT EXISTS
- 6. Oracle special case: MINUS instead of EXCEPT
- 7. MySQL availability and older versions
- 8. How null values are handled in set operators
- 9. Decision guide: when switching is worth it
- 10. Summary
- 11. FAQ
1. Basic principle: set comparison instead of row combination
A JOIN combines rows from two tables based on a matching key and returns a combined row from both sources. INTERSECT and EXCEPT work fundamentally differently: both compare two complete result sets with identical column structure against each other and return a set of the same structure, not a combined row but a filtered one. That makes them the natural choice for questions that are genuinely set theoretic in nature, for example which customers ordered both this quarter and last quarter, or which products are held in stock but have never been sold.
Technically both operators behave like UNION: they require the same number of columns with compatible data types in both subqueries, and both remove duplicates from the result by default, similar to UNION without ALL. Some databases now also offer INTERSECT ALL and EXCEPT ALL, which handle duplicates by taking their frequency into account instead of removing them entirely, which can matter for certain inventory comparisons.
2. INTERSECT in practice: customers who ordered in two periods
A classic use case for INTERSECT is finding customers who placed at least one order in each of two different time periods, for example to distinguish repeat buyers from one time buyers. Instead of building a query with a double self join and matching date filters, the INTERSECT version reads almost exactly like the original business question.
The decisive readability advantage shows up once more than two conditions need to be combined. A chain of three or four INTERSECT joins remains extendable by exactly one additional SELECT block for each further condition, while the equivalent JOIN solution requires an additional join and additional null handling for each further condition, which quickly makes the query hard to follow.
-- Customers who ordered in both Q1 and Q2
SELECT customer_id FROM orders
WHERE order_date BETWEEN '2026-01-01' AND '2026-03-31'
INTERSECT
SELECT customer_id FROM orders
WHERE order_date BETWEEN '2026-04-01' AND '2026-06-30';
3. EXCEPT in practice: products with no sales
EXCEPT returns every row from the first query that does not appear in the second query, which makes it excellent for typical difference questions like which products are held in stock but have not sold a single unit in the current year. The semantic closeness to the business question makes the code noticeably more accessible to colleagues without deep SQL expertise than a nested NOT EXISTS construct with a correlated subquery.
It is important to note that EXCEPT, like INTERSECT, operates on the full row, not just a single key column. If only certain columns should drive the comparison, both SELECT lists must be reduced exactly to those columns, otherwise even small differences in accompanying columns like a timestamp will cause a row that is otherwise identical to be incorrectly treated as different.
-- Products in stock with no sales in the current year
SELECT product_id FROM inventory
WHERE quantity_on_hand > 0
EXCEPT
SELECT product_id FROM order_items
WHERE order_date >= '2026-01-01';
4. The equivalent JOIN and NOT EXISTS variants compared
The same INTERSECT query can also be written with an INNER JOIN on the customer id, additionally requiring DISTINCT to remove duplicates from multiple orders within the same period. This version is functionally equivalent, but it requires the reader to mentally reconstruct that a pure existence check is intended, even though the syntax describes a join with all its possible side effects such as duplication in one to many relationships right there in the code.
The EXCEPT query can equivalently be written with a NOT EXISTS subquery that checks, for every row of the outer query, whether a matching row exists in the second set. NOT EXISTS is functionally very close to EXCEPT and in many cases even faster, because the optimizer often executes a correlated subquery as an anti join, but the code becomes noticeably harder to read than a chain of several EXCEPT blocks once multiple conditions are chained together.
-- Equivalent to EXCEPT, functionally identical
SELECT i.product_id
FROM inventory i
WHERE i.quantity_on_hand > 0
AND NOT EXISTS (
SELECT 1 FROM order_items oi
WHERE oi.product_id = i.product_id
AND oi.order_date >= '2026-01-01'
);
5. Performance comparison: set operator vs. JOIN vs. NOT EXISTS
In modern optimizer implementations of PostgreSQL, SQL Server, and Oracle, INTERSECT and EXCEPT are frequently mapped internally onto the same execution plan as a semantically equivalent NOT EXISTS or semi join construct, especially when both subqueries are based on indexed columns. In practice, a look at EXPLAIN ANALYZE often shows that runtime barely differs between the three variants on well indexed tables, since the optimizer correctly recognizes the actual intent behind all three formulations.
One relevant difference remains though: INTERSECT and EXCEPT implicitly force duplicate removal across the entire row, which on very wide result sets can mean an additional sort or hash step that a deliberately written NOT EXISTS variant without DISTINCT does not need. For performance critical queries on very large tables, an explicit plan comparison is therefore worthwhile before choosing the set operator purely for readability reasons.
6. Oracle special case: MINUS instead of EXCEPT
Oracle has historically never had an EXCEPT operator, using the keyword MINUS instead with identical semantics: every row from the first query that does not appear in the second query. Anyone who wants to keep code portable between Oracle and other databases must explicitly account for this detail, since an EXCEPT copied over verbatim gets rejected in Oracle with a plain syntax error.
INTERSECT, on the other hand, is available in Oracle under the same name as in the SQL standard and behaves identically to PostgreSQL, SQL Server, and MySQL from version 8.0.31 onward. For database agnostic code, either a central abstraction layer that swaps MINUS and EXCEPT depending on the target database is recommended, or simply avoiding EXCEPT from the start in favor of a NOT EXISTS formulation that works identically across all relevant databases.
-- Oracle version of EXCEPT
SELECT product_id FROM inventory
WHERE quantity_on_hand > 0
MINUS
SELECT product_id FROM order_items
WHERE order_date >= DATE '2026-01-01';
7. MySQL availability and older versions
MySQL only supports INTERSECT and EXCEPT since version 8.0.31, considerably later than most other set operations, which had long been available. In older MySQL versions, which are still running in plenty of production environments, both operations must necessarily be recreated with JOIN or NOT EXISTS constructs, there is no native alternative.
For migration projects that need to support code across older and newer MySQL versions at the same time, a deliberate decision is worth making: either consistently use the compatible NOT EXISTS formulation, or add a version check up front and switch between the two variants depending on the target environment. For new projects that run exclusively on MySQL 8.0.31 or newer, PostgreSQL, SQL Server, or Oracle, there is little reason to avoid the native operators directly.
8. How null values are handled in set operators
An often overlooked difference from a classic equality check with the equals operator concerns null values. While a JOIN on equals never treats two null values as equal, because null equals null in SQL fundamentally evaluates to unknown instead of true, INTERSECT and EXCEPT treat two rows with null in the same column as equivalent for the purpose of set comparison. That matches the behavior of DISTINCT, not the behavior of an equality operator in a WHERE clause.
This difference can lead to surprising results when a team replaces a NOT EXISTS expression with EXCEPT without accounting for this detail: rows with null in the comparison column that would have survived under NOT EXISTS due to SQL's three valued logic can drop out under EXCEPT, because they are considered equivalent to another null row in the second set. An explicit test with real null values in the test data is therefore essential when migrating between the two formulations.
9. Decision guide: when switching is worth it
INTERSECT and EXCEPT are particularly worthwhile when the business question is genuinely phrased in set theoretic terms, when several similar conditions need to be chained together, or when the code is read by people less familiar with complex JOIN constructs. They are less suitable when additional columns from both sources need to appear in the result, since a classic JOIN is fundamentally the right choice for that, because set operators only ever return rows matching the structure of the first subquery.
As a rule of thumb: whenever the phrasing which rows appear in both sets or which rows are missing from the second set sounds natural, a set operator is usually the right choice. As soon as additional information from the second source is needed in the result, for example an order date or a product name from the joined table, there is no way around a classic JOIN.
| Operator | Meaning | Oracle name | MySQL since version |
|---|---|---|---|
| INTERSECT | Rows present in both sets at once | INTERSECT | 8.0.31 |
| EXCEPT | Rows only in the first set | MINUS | 8.0.31 |
| INTERSECT ALL | like INTERSECT, considers frequency | not available | not available |
| EXCEPT ALL | like EXCEPT, considers frequency | not available | not available |
| Equivalent via JOIN | INNER JOIN with DISTINCT | identical | available since always |
| Equivalent via NOT EXISTS | correlated subquery as anti join | identical | available since always |
Mironsoft
Database optimization, query tuning, and migrations
SQL queries that keep getting slower as the data grows?
We analyze and optimize SQL databases regardless of the system in use, plan safe migrations and schema changes, and teach teams query optimization hands-on.
Query Optimization
Analyze slow queries and speed them up with purpose using indexes and explain plans.
Migration Planning
Execute schema changes and data migrations safely, without downtime.
Team Training
Anchor SQL fundamentals and performance thinking hands-on in the dev team.
10. Summary
INTERSECT and EXCEPT: Key Takeaways
Set comparison instead of joining
INTERSECT and EXCEPT compare two complete result sets and return filtered rows instead of combined rows like a JOIN does.
Oracle uses MINUS
Anyone writing portable code needs to replace EXCEPT with the keyword MINUS on Oracle, INTERSECT stays unchanged.
Performance usually on par
With good indexing, the optimizer often turns set operators, JOINs, and NOT EXISTS constructs into similar execution plans.
Null handling like DISTINCT
Unlike an equality operator, both operators treat two null values as equivalent for the purpose of set comparison.