SQL Set Operators · Guide + 20 Interview Questions
Using Set Operators in SQL: UNION, INTERSECT, MINUS, EXCEPT
A join combines columns from two tables side by side, while a set operator combines rows from
two queries vertically — one result placed on top of another. SQL has four main set operators:
UNION combines the results and removes duplicate rows, UNION ALL
combines them and keeps duplicates, INTERSECT returns only the rows that appear in
both results, and MINUS in Oracle or EXCEPT in SQL Server and
PostgreSQL returns rows that exist in the first query but not the second. This guide covers all
four operators, the rules for combining result sets, and how NULL values and
duplicates are handled, followed by twenty questions commonly asked in SQL interviews using the
same customer and order tables throughout the series.
🎯 Key Takeaways
UNION merges and removes duplicates
UNION ALL merges and keeps duplicates
INTERSECT returns rows in both result sets
MINUS / EXCEPT returns rows in the first only
All sides must have the same column count and compatible types
20 Interview QuestionsBeginner to AdvancedReal ExamplesUpdated Oct 7, 2026
KW
Kailas Warade
✓ Verified Author
Data Engineer · Oracle Certified · SQL & PL/SQL
Kailas works with SQL, PL/SQL, Python, and PySpark to build data pipelines.
He writes the SQL interview guides at Sankalan Data Tech, focused on
practical, real-world answers.
🏆 Oracle Certified💼 Data Engineer📝 SQL & PL/SQL Guides📍 Pune, India
A join combines data from two tables based on a matching condition. The result usually
becomes wider because columns from both tables are returned. A set operator works
differently: it combines the complete result sets of two queries by stacking the rows
vertically. The result therefore has more rows rather than more columns.
The main set operators come from set theory. UNION represents the union of
two result sets, INTERSECT returns their intersection, and
MINUS or EXCEPT returns the set difference. Set operators
compare rows from the two result sets based on their corresponding columns. A row is
included according to whether a matching row exists in the other result set and according
to the rules of the particular operator.
This makes set operators useful when two queries return the same type of information and
you need to combine or compare those results. For example, you might combine customer
records from two systems, find order IDs that exist in a batch file but not in the
database, or identify product codes that appear in both an active and an archived
catalog. These are good examples of problems that can be solved with set operators.
Set operators are not a replacement for joins. If you need to match rows using a range,
multiple conditions, or relationships between columns from two tables, a join is usually
the better choice. Set operators are mainly designed for combining or comparing compatible
result sets.
The Sample Data We Use in Every Example
Same customers and orders tables as the rest of the series. Most of the set operator
examples below either combine data from these two tables or contrast their contents.
For the overlap examples, imagine a second hypothetical table called
archived_customers that shares some customer ids with the live table.
And for the examples that need two sources, imagine a companion table:
-- archived_customers table
-- customer_id | customer_name
-- C001 | Alice Corp (same as live)
-- C002 | Bob Traders (same as live)
-- C008 | Henry Co (only in archive)
-- C009 | Ivan Trading (only in archive)
That setup is enough to demonstrate every set operator. UNION of customers and
archived_customers gives the full list. INTERSECT gives the ones that appear in both.
MINUS gives the ones that are new or retired. Each operator does one job cleanly.
The Two Rules That Govern Every Set Operation
Before we go through each operator, there are two rules that apply to all of them.
Breaking either one causes the query to fail with an error.
Rule one — same number of columns
-- This fails
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id FROM archived_customers;
-- ERROR: each UNION query must have the same number of columns
The first query returns two columns, the second returns one. The database cannot line
them up, so it refuses to run the statement. There is no partial result — the whole
query fails.
The fix is to either pad the second query with an extra column:
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id, customer_name FROM archived_customers;
Or to substitute a NULL for the missing column:
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id, NULL FROM archived_customers;
Both work. The first is the cleaner form when the tables really do have matching
structure. The second is useful when you are combining tables with different shapes and
want to fill in the gaps.
Rule two — compatible data types
-- This fails if customer_id is VARCHAR on one side and NUMBER on the other
SELECT customer_id FROM customers
UNION
SELECT order_id FROM orders;
-- ERROR: expression must have same datatype as corresponding expression
The column types have to be compatible — same type, or types that the database can
implicitly convert between. Numbers with numbers, strings with strings, dates with
dates. Text that looks like a number does not count as a number, so if one side stores
customer ids as 'C001' and the other stores them as 1, the
query fails.
The fix is an explicit cast on one side:
SELECT CAST(customer_id AS VARCHAR2(20)) FROM customers
UNION
SELECT CAST(order_id AS VARCHAR2(20)) FROM orders;
Both sides now produce strings, and the UNION works.
Column names come from the first SELECT
SELECT customer_name AS name, customer_id AS id FROM customers
UNION
SELECT customer_name, customer_id FROM archived_customers;
-- The output columns are named "name" and "id", not "customer_name" and "customer_id"
The column names in the final result are taken from the first SELECT. Aliases on the
second SELECT are ignored. That matters when the output is used downstream — a report
that expects a column called name will find it if the first SELECT aliased
it, and will not find it if only the second SELECT did.
UNION — Combine and De-duplicate
UNION stacks two result sets and removes duplicates. A row that appears in
both sets comes back once.
The basic example
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id, customer_name FROM archived_customers;
Every customer id that appears in either table shows up once. C001 and C002 appear in
both, so they show up once each. C003 through C007 appear only in customers. C008 and
C009 appear only in archived_customers. Total distinct ids: nine.
Without the UNION, you would have two separate result sets. The UNION combines them
into one and de-duplicates along the way.
How the de-duplication works
The database does not check each row individually. It runs the two queries, collects
all the rows, sorts them (or hashes them), and then removes any that appear more than
once. That sort-and-compare step is what makes UNION slower than UNION ALL — the
database has to spend real time finding the duplicates.
On small result sets, the difference is invisible. On a UNION that combines two
million-row tables, the sorting step can take longer than the queries themselves.
When to reach for UNION
Combining customer lists from two systems where overlap is possible and you want a clean, deduplicated output.
Producing a distinct list of values from multiple sources — all the region names that appear in any table, for example.
Comparing two result sets by combining them and counting the difference in total row count.
The general rule: reach for UNION when duplicates are possible and you do not want them.
If you know the two sides cannot overlap, or you do want duplicates, use UNION ALL.
UNION ALL — Combine and Keep Everything
UNION ALL stacks two result sets without doing anything about duplicates.
Every row from both sides comes back, including rows that appear in both.
The basic example
SELECT customer_id, customer_name FROM customers
UNION ALL
SELECT customer_id, customer_name FROM archived_customers;
Seven rows from customers plus four rows from archived_customers equals eleven rows in
the result. C001 and C002 each appear twice — once from each side. The result is taller
than the UNION version and includes all the duplicates.
Why UNION ALL is faster
No sorting, no hashing, no comparison. The database just concatenates the two result
sets and returns them. On a large union of tables, that means the whole operation is
a single pass over the data instead of a sort plus a pass.
-- On a million-row customers table
UNION → sort + dedupe + return (expensive)
UNION ALL → concatenate + return (cheap)
The performance gap can be an order of magnitude on large tables. When a query uses
UNION and runs slowly, the first question is whether it should have been UNION ALL.
When to reach for UNION ALL
You know the two sides cannot overlap — two date ranges that do not intersect, two regions that are mutually exclusive.
You want to preserve duplicates because they mean something — an audit trail, a log of events.
You are combining pre-aggregated results where each side already produces distinct rows.
Performance matters and the de-duplication cost is not worth it.
The general rule: default to UNION ALL, and switch to UNION only when you specifically
need duplicates removed. It is easier to add de-duplication than to remove it, and
UNION ALL rarely surprises you.
The single most common interview mistake with UNION: forgetting that
UNION de-duplicates. Candidates write a UNION when they meant UNION ALL and then wonder
why the row count is lower than expected. Before you write UNION, ask yourself whether
the two sides could overlap. If the answer is yes and you want both, use UNION ALL.
INTERSECT — The Overlap
INTERSECT returns only the rows that appear in both result sets. It is the
set intersection — the overlap. Everything not in both is dropped.
The basic example
SELECT customer_id, customer_name FROM customers
INTERSECT
SELECT customer_id, customer_name FROM archived_customers;
Only C001 and C002 come back — the two customers that appear in both tables. Everyone
else is excluded, either because they are only in the live table or only in the archive.
How INTERSECT compares to an INNER JOIN
On two tables with matching keys, an INNER JOIN and an INTERSECT often produce similar
results. But they are different operations. The INNER JOIN compares columns from the
two tables and combines rows where the join condition matches. INTERSECT compares entire
rows for equality. If you want all the columns from both sides, use INNER JOIN. If you
want only the rows that are identical across the two result sets, use INTERSECT.
-- INNER JOIN — combines columns from both tables
SELECT c.customer_id, c.customer_name, a.customer_name AS archived_name
FROM customers c
JOIN archived_customers a ON c.customer_id = a.customer_id;
-- INTERSECT — returns the rows that appear identically in both
SELECT customer_id, customer_name FROM customers
INTERSECT
SELECT customer_id, customer_name FROM archived_customers;
Different shape, different output. The INNER JOIN gives you a wide result with both
versions of the name. INTERSECT gives you a narrow result with just the ones that match.
When to reach for INTERSECT
Finding customers who appear in both an active and an archived list.
Identifying products that sold in two different time periods.
Comparing two systems and finding the records they agree on.
Overlap analysis — "which of our current users also appear in the acquisition list?"
INTERSECT removes duplicates automatically. If a value appears three times in each side,
it appears once in the result. That is usually what you want for overlap analysis — the
fact that both sides have the value matters more than how many times.
MINUS and EXCEPT — The Difference
MINUS returns the rows from the first result set that do not appear in
the second. EXCEPT is the same operation with a different name.
The two names for one operation
-- Oracle syntax
SELECT customer_id FROM customers
MINUS
SELECT customer_id FROM archived_customers;
-- SQL Server, PostgreSQL syntax
SELECT customer_id FROM customers
EXCEPT
SELECT customer_id FROM archived_customers;
Both queries do the same thing. Oracle calls it MINUS; SQL Server and
PostgreSQL call it EXCEPT. A few databases accept both spellings. The
behaviour is identical — the first result set minus the rows that appear in the second.
On our data, that returns C003, C004, C005, C006, C007 — every customer who is in the
live table but not in the archive. These are the customers added since the last archive
was taken.
The other direction is a different question
SELECT customer_id FROM archived_customers
MINUS
SELECT customer_id FROM customers;
That returns C008 and C009 — customers who are in the archive but not in the live table.
These are the customers who have been retired or deleted.
The two directions of MINUS are different queries with different meanings. "What is new?"
and "What is retired?" are both useful questions, and the order of the SELECT statements
is what distinguishes them.
When to reach for MINUS or EXCEPT
Finding records that exist in one system but not the other.
Auditing what has changed between two snapshots of a table.
Comparing a source table against a target table to find rows that did not make it into the target.
Identifying orphaned records — customers in a list without a matching account, products without a category.
MINUS and EXCEPT de-duplicate automatically, like INTERSECT and UNION. If a value appears
multiple times in the first result set and never in the second, it appears once in the
output.
One trap with MINUS: it is not directional in the way joins are. You
cannot say "the rows that appear in the first but not the second with a condition".
MINUS compares whole rows for equality. If you want "rows where column X matches but
column Y differs", you need a join or an anti-join with a subquery, not MINUS.
How NULL Behaves in Set Operations
NULL is treated differently in a set operation than it is in a normal comparison. That
catches people out.
The rule — NULL equals NULL in set operations
SELECT customer_id, credit_limit FROM customers
UNION
SELECT customer_id, credit_limit FROM archived_customers;
If Fiona Mart appears in both tables with a NULL credit_limit, the UNION treats the two
rows as duplicates and keeps only one. That is different from a normal comparison, where
NULL = NULL evaluates to unknown and fails.
Same for INTERSECT. If NULL appears in the same column position on both sides, INTERSECT
considers it a match and the row survives.
Why this matters in practice
If you were comparing two customer tables with an equality join on the credit_limit
column, NULL credit limits would never match — because NULL = NULL is
always unknown. But if you use INTERSECT to find rows that are identical on both sides,
the NULL credit limits do match, and those rows appear in the result.
That difference makes INTERSECT and MINUS more reliable than a hand-written comparison
when NULLs are involved. They treat NULL as a comparable value — the same in both places
or not the same — which is what most people intuitively expect.
When you want different NULL handling
SELECT customer_id, COALESCE(credit_limit, 0) AS credit_limit FROM customers
UNION
SELECT customer_id, COALESCE(credit_limit, 0) AS credit_limit FROM archived_customers;
If NULL should be treated as a distinct value — say, "not assessed" is meaningfully
different from "assessed at 0" — replace the NULL with a sentinel value before the set
operation. Both sides use the same substitution, so the comparison is consistent.
The sentinel should be a value that cannot naturally appear in the data. For a numeric
column, use a negative number like -1 if the real values are always positive. For a
date column, use a very old date. The point is that after substitution, the sentinel
and the real values are distinct and comparable.
ORDER BY with Set Operators
The ORDER BY clause goes at the very end of a set operation, after the last SELECT. It
sorts the combined result, not the individual SELECT statements.
The correct form
SELECT customer_name FROM customers
UNION
SELECT customer_name FROM archived_customers
ORDER BY customer_name;
The ORDER BY applies to the whole union, not to either side individually. On our data,
that returns every unique customer name sorted alphabetically — Alice Corp, Bob Traders,
Charlie Ltd, Diana Retail, Ethan Supplies, Fiona Mart, George Wholesale, Henry Co, Ivan
Trading.
The incorrect form
-- This fails or behaves unexpectedly
SELECT customer_name FROM customers ORDER BY customer_name
UNION
SELECT customer_name FROM archived_customers;
The ORDER BY is inside the first SELECT. Most databases reject this with a syntax error.
A few accept it but produce a result you did not intend — the ORDER BY is ignored, or
it applies in an odd way. Either way, it is not what you want.
Ordering by position or by alias
-- Order by column position
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id, customer_name FROM archived_customers
ORDER BY 2;
-- Order by the column name from the first SELECT
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id, customer_name FROM archived_customers
ORDER BY customer_name;
Both work. Positional ordering (ORDER BY 2) sorts by the second column in the result.
Named ordering (ORDER BY customer_name) sorts by the column with that name. The named
form is more readable and less fragile — insert a new column in the SELECT and the
positional reference breaks, while the named reference still works.
If the first SELECT aliases a column, the alias is what ORDER BY sees:
SELECT customer_name AS name FROM customers
UNION
SELECT customer_name FROM archived_customers
ORDER BY name; -- works — "name" is the alias from the first SELECT
That is consistent with the rule that the first SELECT determines the column names of
the combined result.
Set Operators vs Joins
Both combine data from multiple sources. They do it in completely different ways, and
choosing between them is one of the most common design decisions in SQL.
The fundamental difference
Aspect
JOIN
Set operators
Direction of combination
Side by side (more columns)
Stacked vertically (more rows)
Matching rule
Any boolean condition
Whole-row equality only
Duplicate handling
Keeps duplicates from matches
UNION de-duplicates by default
Result shape
Columns from both tables
Columns from the first SELECT
Typical use
"Show me orders with their customer names"
"Show me customers who appear in both systems"
The shapes they produce
-- JOIN: wider result
SELECT c.customer_name, o.order_id, o.order_amount
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
-- UNION: taller result
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id, customer_name FROM archived_customers;
The JOIN produces one row per order with the customer name attached — three columns,
seven rows. The UNION produces one row per distinct customer — two columns, nine rows.
Different shapes, different purposes.
When to use which
JOIN — when you need columns from multiple tables in the same row. Reports, detail views, anything where the two tables describe different aspects of one entity.
Set operators — when you have two sources producing the same kind of row and you want to combine or compare them. Consolidation, deduplication, overlap, difference.
The question to ask yourself: do I need to bring different columns together, or do I need
to bring the same kind of row from two places? If it is the first, use a JOIN. If it is
the second, use set operators.
Combining Set Operators with Aggregates
Set operators work fine with aggregate functions, as long as each side of the operation
is producing the same kind of row.
The typical pattern
SELECT c.region,
SUM(o.order_amount) AS total_revenue
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.region
UNION ALL
SELECT c.region,
SUM(c.credit_limit) AS total_revenue
FROM customers c
GROUP BY c.region;
Each SELECT produces a (region, amount) pair. One is revenue from orders,
the other is credit exposure from customer credit limits. The UNION ALL stacks them,
and each row carries a label in the second column that tells you which side it came
from.
On our data, that returns six rows — three regions times two sources. The revenue rows
have real sums, and the credit rows have real sums. Because the aggregation happened
before the UNION, each side was deduplicated naturally by the GROUP BY.
Why GROUP BY before the UNION matters
Without the GROUP BY, the individual rows would appear and the UNION would try to
deduplicate them across sides — which would be wrong, because the columns mean
different things. GROUP BY ensures each side produces one row per group, and the UNION
then combines cleanly.
Aggregates across the combined result
SELECT region, SUM(total_revenue)
FROM (
SELECT c.region, SUM(o.order_amount) AS total_revenue
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.region
UNION ALL
SELECT c.region, SUM(c.credit_limit)
FROM customers c
GROUP BY c.region
) combined
GROUP BY region;
Wrap the union in a subquery and you can aggregate across the combined result. That
lets you sum the total of both sources per region, or count how many rows each side
contributes. Anything you can do with a result set, you can do with the result of a
set operation — because a set operation just produces another result set.
Performance — When UNION Gets Expensive
Set operators are not free. The cost depends entirely on which operator you use and how
big the result sets are.
UNION ALL — cheap
SELECT customer_id FROM customers
UNION ALL
SELECT customer_id FROM archived_customers;
The database runs both queries and concatenates the results. The cost is the sum of the
two queries' costs, plus a tiny bit for the concatenation. On large tables, that means
UNION ALL is essentially free.
UNION, INTERSECT, MINUS — expensive
SELECT customer_id FROM customers
UNION
SELECT customer_id FROM archived_customers;
Now the database has to sort or hash both result sets to find duplicates and matches.
On two million-row tables, that step can take as long as the queries themselves. The
total cost is roughly twice the query cost plus the cost of the sort.
The exact algorithm depends on the database. Sort-based and hash-based implementations
both exist. Either way, the operation scales worse than UNION ALL — it is closer to
O(n log n) than O(n).
Reducing the cost
Use UNION ALL whenever duplicates do not matter.
Filter each side with a WHERE clause before the set operator — smaller inputs mean faster sorting.
Consider whether an EXISTS or NOT EXISTS subquery would answer the same question more cheaply. Anti-joins are often faster than MINUS for the same logic.
On very large sets, check the execution plan. If it shows a sort on both sides, and the tables are large, there may be a cheaper formulation.
Comparing MINUS to NOT EXISTS
-- MINUS
SELECT customer_id FROM customers
MINUS
SELECT customer_id FROM archived_customers;
-- NOT EXISTS — same result, often faster
SELECT c.customer_id
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM archived_customers a WHERE a.customer_id = c.customer_id
);
Both return the same rows. On modern databases, the NOT EXISTS version is
often faster because the optimizer can use an index on the subquery's filter column
and short-circuit as soon as it finds a match. The MINUS version has to fully materialise
both sides before comparing.
That is not a rule — sometimes MINUS is faster. But it is worth testing both when a
set operation is the bottleneck. The rewrite is usually trivial and the difference can
be significant on large tables.
20 Interview Questions and Answers
These are the set operator questions that come up most often. Every answer stands on its
own — read them in any order, or work through them in sequence to build up the full
picture.
/
🔍
No matching questions found
Try a keyword like UNION, INTERSECT, or MINUS.
Set operators combine the results of two or more SELECT statements into a single
result set. They come from set theory — union, intersection, difference — and
they work by comparing whole rows across the two sides.
SELECT customer_name FROM customers
UNION
SELECT customer_name FROM archived_customers;
That merges the two customer lists and removes duplicates. Every unique name that
appears in either table shows up once.
The five set operators are:
UNION — combine both sides, remove duplicates
UNION ALL — combine both sides, keep duplicates
INTERSECT — rows that appear in both sides
MINUS — rows in the first side that are not in the second (Oracle)
EXCEPT — same as MINUS (SQL Server, PostgreSQL)
Two rules apply to all of them. Both sides must return the same number of columns,
and the matching columns must have compatible data types. Break either rule and
the query fails — no partial results.
The difference is whether duplicates are removed. UNION removes them; UNION ALL
keeps them.
-- UNION: combines and removes duplicates
SELECT customer_name FROM customers
UNION
SELECT customer_name FROM archived_customers;
-- UNION ALL: combines and keeps duplicates
SELECT customer_name FROM customers
UNION ALL
SELECT customer_name FROM archived_customers;
If Alice Corp appears in both tables, the UNION version returns her once. The
UNION ALL version returns her twice — once from each side.
Performance follows directly from that. UNION has to sort or hash both result sets
to find the duplicates. UNION ALL just concatenates them. On large tables, UNION
can be several times slower than UNION ALL for the same inputs.
My rule of thumb: default to UNION ALL, and switch to UNION only when you
specifically need de-duplication. Most of the time, the two sides cannot overlap
and de-duplication is wasted work.
INTERSECT returns only the rows that appear in both result sets. It
is the set-theory intersection — the overlap.
SELECT customer_id FROM customers
INTERSECT
SELECT customer_id FROM archived_customers;
That returns only the customer ids that appear in both tables. On our data, that
is C001 and C002. Every other customer is excluded, either because they are only
in the live table or only in the archive.
INTERSECT removes duplicates automatically. If C001 appeared three times in
customers and twice in archived_customers, the result would still show C001 once.
That makes INTERSECT useful for overlap analysis where the fact of the overlap
matters more than the count.
A common use case: comparing two systems and finding the records they agree on.
Or finding customers who purchased in two different time periods, or products
that appear in multiple categories. Anywhere you want the overlap, INTERSECT is
the natural fit.
EXCEPT returns the rows from the first result set that do not appear
in the second. It is the set difference — what is in one but not the other.
SELECT customer_id FROM customers
EXCEPT
SELECT customer_id FROM archived_customers;
That returns every customer id in the live table that is not in the archive.
On our data, that is C003, C004, C005, C006, C007 — the customers added since the
archive was taken.
The direction matters. Swap the two SELECT statements and you get the opposite
question answered:
SELECT customer_id FROM archived_customers
EXCEPT
SELECT customer_id FROM customers;
That returns C008 and C009 — customers who are in the archive but not in the live
table. These are the ones who have been retired or deleted.
EXCEPT is the SQL Server and PostgreSQL name for this operation. On
Oracle, the same operation is called MINUS. Some databases accept
both spellings; the behaviour is identical.
EXCEPT removes duplicates automatically, like the other set operators. A value
that appears five times on the first side and never on the second appears once
in the result.
UNION removes them. When the same row appears on both sides of a UNION, only
one copy survives in the result.
SELECT customer_name FROM customers
UNION
SELECT customer_name FROM archived_customers;
If Alice Corp appears in both tables, that query returns her once. Same for any
other name that happens to be in both. The result is the distinct set of names
across the two tables.
Under the hood, the database implements this by sorting (or hashing) the combined
result and then keeping only the first occurrence of each unique row. Two rows are
considered duplicates only if every column in the row matches — the same customer
id, the same name, the same region, and so on.
That is why UNION is slower than UNION ALL. The sort-and-compare step has real
cost, and it scales with the size of the combined result set.
If you know the two sides cannot overlap, or you do not care about duplicates,
UNION ALL does the same concatenation without the de-duplication step. It is
always at least as fast, and often much faster.
INTERSECT returns the rows that appear in both result sets. Anything that appears
on only one side is dropped. That makes it the natural tool for finding overlap.
SELECT customer_id FROM orders_2023
INTERSECT
SELECT customer_id FROM orders_2024;
That returns every customer id that placed at least one order in both 2023 and
2024. These are the repeat customers — the ones who came back.
The output is deduplicated. A customer who ordered five times in 2023 and three
times in 2024 appears once. That is usually what you want for overlap analysis —
the fact of the overlap matters more than how many orders were involved.
Compare to an alternative with an INNER JOIN:
SELECT DISTINCT o23.customer_id
FROM orders_2023 o23
JOIN orders_2024 o24 ON o23.customer_id = o24.customer_id;
That returns the same rows but you have to add DISTINCT yourself,
because the join produces one row per matching pair. INTERSECT does that
automatically, which is one reason it is often the cleaner choice for overlap.
The two approaches differ when there are other columns involved. INTERSECT
compares the full row, so if you select three columns and only want the overlap
on two of them, INTERSECT will not do what you expect. That is where the join is
more flexible.
Set operators combine rows from two queries vertically — the result is taller.
Joins combine columns from two tables horizontally — the result is wider.
-- JOIN: wider result
SELECT c.customer_name, o.order_id, o.order_amount
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
-- UNION: taller result
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id, customer_name FROM archived_customers;
The JOIN returns one row per order with the customer name attached — three
columns, seven rows. The UNION returns one row per customer across both tables —
two columns, nine rows.
The matching rule is also different. A JOIN can match on any condition — equality,
range, inequality, complex boolean logic. A set operator only compares whole
rows for equality. You cannot say "combine rows where column A is greater than
column B" with a set operator.
Duplicates are handled differently too. A JOIN keeps every matching pair, so a
customer with three orders appears three times in the joined result. A UNION
removes duplicates by default, so a customer who appears in both source tables
shows up once.
Which to use depends on the question. "Show me orders with their customer names"
is a join. "Show me customers who appear in both systems" is a set operator.
No. Every SELECT involved in a set operation must return exactly the same number
of columns. If they do not, the query fails with an error.
-- This fails
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id FROM archived_customers;
-- ERROR: each UNION query must have the same number of columns
The first query returns two columns, the second one column. The database cannot
align them, so it refuses to run the statement. No partial results, no warnings
— just a hard failure.
The fix depends on what you meant. If both tables really do have matching
structure, just list the same columns on both sides:
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id, customer_name FROM archived_customers;
If the two tables have different shapes, pad the shorter one with NULLs or
default values:
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id, NULL FROM archived_customers;
That gives every row two columns. The rows from the second table have NULL in
the second column because there is no real value to fill in.
The column names come from the first SELECT. That means the alias on the second
SELECT is ignored — anything downstream that references columns by name will see
the names from the first query.
MINUS is Oracle's name for the set-difference operation. It returns
rows from the first SELECT statement that are not present in the second.
SELECT customer_id FROM customers
MINUS
SELECT customer_id FROM archived_customers;
That returns every customer id in the live table that is not in the archive. On
our data, that is C003 through C007 — the ones added since the archive was taken.
MINUS is equivalent to EXCEPT on other databases. SQL Server and
PostgreSQL use EXCEPT; Oracle uses MINUS. The behaviour is identical. Some
databases support both spellings.
Like the other set operators, MINUS de-duplicates automatically. If a value
appears five times on the first side and never on the second, it appears once
in the result.
Direction matters. Swapping the two SELECT statements produces a different
result — the opposite question. "What is new?" versus "What has been retired?"
are both useful, and the order tells the database which one you want.
In set operations, NULL is treated as equal to NULL. That is different from
normal comparisons, where NULL = NULL evaluates to unknown and
fails.
SELECT customer_id, credit_limit FROM customers
UNION
SELECT customer_id, credit_limit FROM archived_customers;
If Fiona Mart appears in both tables with a NULL credit limit, the UNION treats
the two rows as duplicates and keeps only one. That matches most people's
intuition — the two rows describe the same customer with the same (unknown)
credit limit.
Same for INTERSECT. If NULL appears in the same column position on both sides,
INTERSECT considers it a match and keeps the row. If one side has NULL and the
other has 0, they are different rows and both survive.
This is actually helpful because it makes set operators more predictable than a
hand-written equality comparison. Writing WHERE a.col = b.col would
fail for NULLs, but INTERSECT does not.
If you want a different NULL handling — say NULL should be treated as distinct
from another NULL, or NULL should be replaced with a value — do the substitution
before the set operator:
SELECT customer_id, COALESCE(credit_limit, -1) FROM customers
UNION
SELECT customer_id, COALESCE(credit_limit, -1) FROM archived_customers;
Both sides replace NULL with -1, so the set operation compares the -1 values.
Pick a sentinel that cannot naturally appear in the data.
Yes. Any SELECT statement — including a subquery — can be one side of a set
operation. The subquery just has to produce the same column shape as the other
side.
SELECT customer_id FROM customers
UNION
SELECT customer_id FROM (
SELECT customer_id FROM archived_customers WHERE status = 'active'
);
The second side is a subquery that filters archived customers to just the active
ones. The subquery runs first, produces a result set, and the UNION combines it
with the customers side.
You can also use a subquery in the WHERE clause of a side:
SELECT customer_id FROM customers
UNION
SELECT customer_id FROM archived_customers
WHERE archived_on > (SELECT MAX(snapshot_date) FROM snapshots);
Subqueries inside a UNION are common when one side depends on a value computed
from another query. The general rule is that any legal SELECT works — the only
constraint is that the columns have to line up.
One thing to watch for: correlated subqueries inside a UNION side can be slow if
they reference columns from the other side. The sides of a UNION are logically
independent — the database does not cross-reference them during execution — so
correlations that try to do that will not work.
UNION is slower because it has to remove duplicates. UNION ALL skips that step
and runs significantly faster on large tables.
-- UNION: runs both queries, sorts, de-duplicates, returns
SELECT customer_id FROM customers
UNION
SELECT customer_id FROM archived_customers;
-- UNION ALL: runs both queries, concatenates, returns
SELECT customer_id FROM customers
UNION ALL
SELECT customer_id FROM archived_customers;
The extra cost in UNION is the sort or hash operation the database uses to find
duplicates. On two result sets of a thousand rows each, that is trivial. On two
result sets of ten million rows each, the sort can take longer than the queries
themselves.
The magnitude of the difference depends on the database's implementation. Some
databases use sort-based de-duplication, which is O(n log n) on the combined
result. Others use hash-based, which is roughly O(n) but with a large constant
cost for building the hash table. Either way, UNION ALL is at least as fast, and
often several times faster.
When performance matters, default to UNION ALL and switch to UNION only when you
specifically need duplicates removed. That decision alone can save seconds on a
large report.
If you do need UNION's de-duplication and the query is slow, the usual fix is to
filter each side with a WHERE clause before the UNION — smaller inputs mean
faster sorting. Sometimes a different formulation entirely, like a
NOT EXISTS subquery, ends up faster than the UNION.
Put a single ORDER BY at the very end, after the last SELECT statement. It sorts
the combined result, not the individual sides.
SELECT customer_name FROM customers
UNION
SELECT customer_name FROM archived_customers
ORDER BY customer_name;
That returns every unique customer name sorted alphabetically. The ORDER BY
applies to the whole union, not to the customers side or the archive side.
Putting an ORDER BY inside the individual SELECT statements does not work — most
databases reject it with a syntax error, and the ones that accept it usually
ignore it.
-- This fails
SELECT customer_name FROM customers ORDER BY customer_name
UNION
SELECT customer_name FROM archived_customers;
The ORDER BY has to come after the last SELECT. Once it is there, you can order by
column name, column position, or an alias from the first SELECT:
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id, customer_name FROM archived_customers
ORDER BY 2; -- sort by the second column
SELECT customer_name AS name FROM customers
UNION
SELECT customer_name FROM archived_customers
ORDER BY name; -- sort by the alias from the first SELECT
Positional ordering (ORDER BY 2) is fragile — insert a new column in the SELECT
and the position reference breaks. Named ordering is more readable and more
robust.
Four limitations come up often enough to be worth knowing.
Column count and types must match exactly. You cannot combine a
query that returns three columns with one that returns two. You cannot combine a
numeric column with a text column without an explicit cast. That restriction
means set operators work best when the two sides genuinely have the same shape.
Duplicates are removed by default. Every set operator except
UNION ALL de-duplicates, and that de-duplication has a real performance cost.
Forgetting this and using UNION when UNION ALL would do is one of the most
common slow-query causes.
Whole-row equality only. Set operators compare entire rows. You
cannot say "combine rows where column A matches and column B is greater than
column C" — that kind of logic belongs in a join, not a set operation. This
makes set operators unsuitable for any matching rule that is not pure equality.
Limited to specific use cases. Set operators work best for
consolidation, overlap analysis, and set difference. For anything more nuanced —
reports that need columns from multiple tables, comparisons that involve ranges,
queries that need to match on partial keys — a join is almost always the right
tool.
None of these are reasons to avoid set operators. They are reasons to reach for
them when they fit the problem, and reach for joins when they do not.
Yes. Each side of a set operation can have its own aggregate functions and GROUP
BY. The set operator then combines the aggregated results.
SELECT region,
SUM(order_amount) AS total
FROM orders
GROUP BY region
UNION ALL
SELECT region,
SUM(credit_limit) AS total
FROM customers
GROUP BY region;
Each side produces one row per region. The UNION ALL stacks them. The result has
one row per (region, source) combination — six rows for three regions with two
sources each.
GROUP BY before the set operator is important. Without it, the individual rows
would appear and the set operator would try to de-duplicate across sides — which
would be wrong because the numbers mean different things. GROUP BY ensures each
side produces a single row per group, and the set operator combines cleanly.
You can also aggregate across the combined result by wrapping the union in a
subquery:
SELECT region, SUM(total)
FROM (
SELECT region, SUM(order_amount) AS total FROM orders GROUP BY region
UNION ALL
SELECT region, SUM(credit_limit) FROM customers GROUP BY region
) combined
GROUP BY region;
That sums both sources per region. The subquery produces the combined result; the
outer query aggregates it further. Anything you can do with a normal result set,
you can do with the result of a set operation.
If the columns do not match — either in count or in type — the database raises an
error and the query fails.
-- Column count mismatch
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id FROM archived_customers;
-- ERROR: each UNION query must have the same number of columns
-- Data type mismatch
SELECT customer_id FROM customers
UNION
SELECT 123 FROM dual;
-- ERROR: expression must have same datatype as corresponding expression
Both failures are caught at parse time, before any rows are processed. No partial
results, no warnings.
The fix depends on what is wrong. For a column count mismatch, either add columns
to the shorter query or drop columns from the longer one:
-- Pad the shorter side with NULL
SELECT customer_id, customer_name FROM customers
UNION
SELECT customer_id, NULL FROM archived_customers;
For a type mismatch, cast one side to match the other:
SELECT CAST(customer_id AS VARCHAR2(20)) FROM customers
UNION
SELECT CAST(order_id AS VARCHAR2(20)) FROM orders;
Both sides now produce strings of the same length, and the UNION succeeds. The
resulting column uses whatever name the first SELECT gave it.
If you are combining tables that were never designed to line up, this is a
signal. Sometimes the right answer is to build an intermediate query or a view
that presents the data in a consistent shape, and use that as the source of the
set operation.
Set operators require compatible data types on corresponding columns. Two columns
in the same position must either be the same type, or types the database can
implicitly convert.
Compatible combinations vary by database, but the general rules are:
Numeric types — INTEGER, NUMBER, DECIMAL, and FLOAT are usually compatible with each other. The database promotes them to the widest type in the pair.
String types — VARCHAR and CHAR are compatible if the lengths work out. VARCHAR2 and VARCHAR are usually the same on databases that have both.
Date and timestamp — usually compatible with each other. A date can be promoted to a timestamp.
Numbers and strings — not compatible. The database will not implicitly convert '123' to 123 in a set operation.
Strings and dates — not compatible. A string that looks like a date is still a string.
Where the types are not compatible, cast explicitly:
SELECT TO_CHAR(order_date, 'YYYY-MM-DD') AS order_key FROM orders
UNION
SELECT order_key FROM legacy_orders;
That turns a date into a string on the first side so it matches the string column
on the second side.
Explicit casts are better than relying on implicit conversion even when the
database allows it. Implicit conversion rules vary between databases, and a query
that works on Oracle may fail on PostgreSQL because the implicit conversion there
is stricter. Being explicit makes the query portable.
UNION, INTERSECT, MINUS, and EXCEPT de-duplicate automatically. Adding an
explicit DISTINCT inside the SELECT statements does not change the
result — the set operator already does that.
-- These two queries return the same result
SELECT customer_id FROM customers
UNION
SELECT customer_id FROM archived_customers;
SELECT DISTINCT customer_id FROM customers
UNION
SELECT DISTINCT customer_id FROM archived_customers;
The set operator treats its inputs as sets, and a set by definition has no
duplicates. The explicit DISTINCT inside is redundant.
Where this matters: performance. Adding an unnecessary DISTINCT to each side
forces the database to de-duplicate twice — once inside each SELECT, and again
at the set operator. On large tables, that extra de-duplication can cost real
time.
The one exception is UNION ALL. That operator does not de-duplicate, so a
DISTINCT inside one side has a visible effect:
-- These two queries return DIFFERENT results
SELECT customer_id FROM customers
UNION ALL
SELECT customer_id FROM archived_customers;
SELECT DISTINCT customer_id FROM customers
UNION ALL
SELECT customer_id FROM archived_customers;
In the first, if a customer appears three times in customers, all
three rows come through. In the second, the DISTINCT collapses them to one before
the UNION ALL runs, so only one row comes through from that side.
As a rule, do not add DISTINCT inside a set operation unless you specifically
need to de-duplicate one side before the operator runs. In UNION, INTERSECT,
MINUS, and EXCEPT, the operator already handles it.
Set operators with aggregate functions let you combine summaries from different
sources. Each side computes its own aggregates, and the set operator stacks the
results into one report.
SELECT department_id, SUM(salary) AS total_salary
FROM employees_current
GROUP BY department_id
UNION ALL
SELECT department_id, SUM(salary)
FROM employees_archived
GROUP BY department_id;
Each side produces one row per department. The UNION ALL stacks them. If a
department exists on both sides, it appears twice — once for current employees,
once for archived. A reader can tell which is which by looking at the source or
by adding a source label column.
Adding a source label makes the output clearer:
SELECT department_id, SUM(salary) AS total_salary, 'Current' AS source
FROM employees_current
GROUP BY department_id
UNION ALL
SELECT department_id, SUM(salary), 'Archived'
FROM employees_archived
GROUP BY department_id;
Now every row carries a label. That is a common pattern for reports that need to
distinguish between sources — current versus archived, internal versus external,
this year versus last year.
If you want a single row per department with the combined total across both
sources, wrap the union in a subquery:
SELECT department_id, SUM(total_salary)
FROM (
SELECT department_id, SUM(salary) AS total_salary FROM employees_current GROUP BY department_id
UNION ALL
SELECT department_id, SUM(salary) FROM employees_archived GROUP BY department_id
) combined
GROUP BY department_id;
That collapses both sides into one row per department. The subquery does the
combining; the outer query does the final aggregation.
Not directly. UNION combines result sets from SELECT statements. INSERT, UPDATE,
and DELETE do not produce result sets — they modify data.
But you can use a SELECT with UNION as the source for an INSERT:
INSERT INTO all_employees (employee_id, name)
SELECT employee_id, name FROM employees_current
UNION
SELECT employee_id, name FROM employees_archived;
The UNION combines two sources and de-duplicates, and the INSERT writes the
result into the target table. That is a common pattern for consolidating data
from multiple sources into a single table.
For UPDATE and DELETE, the pattern is different. You can filter rows based on a
set operation by putting it in a subquery:
DELETE FROM customers
WHERE customer_id IN (
SELECT customer_id FROM customers
MINUS
SELECT customer_id FROM active_customers
);
That deletes customers whose ids are not in the active list. The MINUS produces
the set of ids to remove, and the outer DELETE acts on it.
Some databases also support MERGE, which is a statement that does
INSERT, UPDATE, and DELETE in one pass based on a comparison of two sources.
MERGE is not a set operator, but it plays a similar role for synchronisation
scenarios.
The rule to remember: UNION is a way of combining result sets. Any statement that
consumes a result set — INSERT ... SELECT is the main one — can use it. Statements
that modify rows directly cannot.
Mistakes I Keep Seeing in Interviews
These come up repeatedly. None of them are exotic, and every one of them is easy to avoid
once you know it is coming.
Mistake one: using UNION when UNION ALL would work. The default habit
should be UNION ALL — switch to UNION only when you specifically need duplicates removed.
This one choice can make the difference between a fast query and a slow one.
Mistake two: putting ORDER BY inside the individual SELECT statements.
The ORDER BY goes at the very end, after the last SELECT, and sorts the combined result.
Anywhere else and the query fails or the ordering is ignored.
Mistake three: mismatched column counts. Both sides have to return the
same number of columns. If they do not, the query fails with a clear error — but
candidates often do not check before running.
Mistake four: forgetting that column names come from the first SELECT.
If you alias a column on the second side but not the first, downstream code that expects
the alias will fail to find it.
Mistake five: assuming NULL does not match NULL in a set operation.
Unlike in a normal comparison, NULL is treated as equal to NULL in UNION, INTERSECT,
and MINUS. That surprises people who are used to the standard NULL behaviour.
Mistake six: expecting MINUS to be a directional subtraction with
conditions. It compares whole rows for equality. Any logic beyond equality — matching
on some columns but not others, comparing ranges — requires a join or a subquery.
Follow-Up Questions Interviewers Ask
Getting the first answer right is only half the interview. What usually decides the
outcome is what happens next. Here is what to prepare for on the set operator topics.
After "What is UNION?" — expect
"What is the difference between UNION and UNION ALL?"
"Does UNION remove all duplicates or only some?"
"Which is faster and why?"
"When would you still use UNION despite the performance cost?"
After "What is INTERSECT?" — expect
"How does INTERSECT differ from an INNER JOIN?"
"Does INTERSECT remove duplicates?"
"How would you find rows that appear in three different tables?"
"What happens if the columns are in a different order on the two sides?"
After "What is MINUS?" — expect
"How is MINUS different from EXCEPT?"
"Does the order of the SELECT statements matter?"
"Can you filter the rows that come back from MINUS?"
"What is the difference between MINUS and NOT IN?"
After "How do NULL values work in set operations?" — expect
"How is that different from normal comparison?"
"When would you want to replace NULL with a sentinel value?"
"Does UNION treat two NULLs as duplicates?"
"What about INTERSECT and MINUS?"
For every question above, prepare a second-level answer. The candidates who pass are not
the ones who memorised the first answer — they are the ones who kept going for another
thirty seconds and said something useful about why it matters.
Quick Quiz
Three questions to check whether the topics above have landed.
1. What is the difference between UNION and UNION ALL?