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
20 Interview Questions Beginner to Advanced Real Examples Updated Oct 7, 2026
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
📤 Share:

On This Page

What Set Operators Really Do

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.

-- customers table
-- customer_id | customer_name    | region | credit_limit
-- C001        | Alice Corp       | North  | 50000
-- C002        | Bob Traders      | South  | 25000
-- C003        | Charlie Ltd      | North  | 75000
-- C004        | Diana Retail     | West   | 40000
-- C005        | Ethan Supplies   | South  | 55000
-- C006        | Fiona Mart       | West   | NULL
-- C007        | George Wholesale | North  | 80000

-- orders table
-- order_id | customer_id | order_amount | order_status
-- 1001     | C001        | 12000        | Delivered
-- 1002     | C002        | 4500         | Shipped
-- 1003     | C001        | 7500         | Delivered
-- 1004     | C003        | 22000        | Pending
-- 1005     | C002        | 3200         | Delivered
-- 1006     | C004        | 18000        | Shipped
-- 1007     | C001        | 9000         | Delivered

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?
2. What does INTERSECT return?
3. Where does ORDER BY go in a set operation?