SQLMentor // learn sql

Set Operators

Set operators combine the results of two or more SELECT statements into a single result set. Unlike JOINs, which match rows horizontally across tables, set operators stack results vertically โ€” placing one query's rows on top of another's.

Oracle supports four set operators: UNION, UNION ALL, INTERSECT, and MINUS. They behave like mathematical set operations: union, intersection, and difference.

Why Use Set Operators?

Set operators answer questions that are awkward to express with JOINs alone:

  • UNION โ€” "Show me all customers and all employees in one combined list."
  • INTERSECT โ€” "Which email addresses appear in both the customer table and the employee table?"
  • MINUS โ€” "Which customers have never placed an order?"

You could rewrite each of these with subqueries or JOINs, but set operators are often clearer and sometimes faster.

Tables Used in This Chapter

We'll use two small tables to illustrate every operator:

employees_us:

emp_id name email
1 Alice alice@company.com
2 Bob bob@company.com
3 Carol carol@company.com

employees_uk:

emp_id name email
10 David david@company.com
11 Bob bob@company.com
12 Eve eve@company.com

Bob appears in both tables with the same email (he's a dual-region employee).

UNION โ€” Combine and Deduplicate

UNION stacks rows from two queries and removes duplicates. The result is a unique set.

SELECT name, email FROM employees_us
UNION
SELECT name, email FROM employees_uk;

Result:

name email
Alice alice@company.com
Bob bob@company.com
Carol carol@company.com
David david@company.com
Eve eve@company.com

Bob appears only once because UNION removes duplicates after combining.

Deduplication compares every column โ€” two rows must match on name AND email to be considered duplicates. If two Bobs had different emails, both would appear.

UNION ALL โ€” Combine, Keep Duplicates

UNION ALL is identical to UNION except it does not remove duplicates. Every row from every query appears in the output.

SELECT name, email FROM employees_us
UNION ALL
SELECT name, email FROM employees_uk;

Result:

name email
Alice alice@company.com
Bob bob@company.com
Carol carol@company.com
David david@company.com
Bob bob@company.com
Eve eve@company.com

Bob appears twice โ€” once from each table.

โœ“
Always prefer UNION ALL when you know there are no duplicates (or when duplicates don't matter). UNION must sort and deduplicate the combined result, which is expensive on large datasets. UNION ALL just concatenates.

INTERSECT โ€” Rows in Both Queries

INTERSECT returns only rows that appear in both queries. It's the equivalent of asking "what overlaps?"

SELECT name, email FROM employees_us
INTERSECT
SELECT name, email FROM employees_uk;

Result:

name email
Bob bob@company.com

Only Bob's row appears in both tables, so only Bob is returned. INTERSECT also removes duplicates within the result.

Real-world use case โ€” find emails that exist in two systems:

-- Emails that appear in BOTH the marketing list and the customer database
SELECT email FROM marketing_subscribers
INTERSECT
SELECT email FROM customers;

MINUS โ€” Rows in First Query, Not in Second

MINUS returns rows from the first query that do not appear in the second. Oracle's MINUS is the same as ANSI SQL's EXCEPT (Oracle 21c also accepts EXCEPT as a synonym).

SELECT name, email FROM employees_us
MINUS
SELECT name, email FROM employees_uk;

Result:

name email
Alice alice@company.com
Carol carol@company.com

Bob was removed because his row also exists in the UK table. David and Eve were never in the US list to begin with, so they're not in the output.

Real-world use case โ€” find customers who have never ordered:

SELECT customer_id FROM customers
MINUS
SELECT customer_id FROM orders;

Column Compatibility Rules

All set operators have strict rules about the queries they combine:

  1. Same number of columns in every SELECT
  2. Compatible data types in each column position (VARCHAR2 matches CHAR, NUMBER matches NUMBER, dates match dates)
  3. Column names come from the first query โ€” names in subsequent SELECTs are ignored
-- This works โ€” names in second query are ignored
SELECT name, email FROM employees_us
UNION
SELECT first_name, contact_email FROM employees_uk;

The result columns will be called name and email (from the first query).

-- This FAILS โ€” column count mismatch
SELECT name, email FROM employees_us
UNION
SELECT name FROM employees_uk;
-- ORA-01789: query block has incorrect number of result columns
-- This FAILS โ€” incompatible types in column 2
SELECT name, hire_date FROM employees
UNION
SELECT name, salary    FROM employees;
-- ORA-01790: expression must have same datatype as corresponding expression

ORDER BY With Set Operators

ORDER BY is allowed only once, at the very end of the entire compound query. It applies to the combined result, not to individual SELECTs.

SELECT name, email FROM employees_us
UNION
SELECT name, email FROM employees_uk
ORDER BY name;

Result:

name email
Alice alice@company.com
Bob bob@company.com
Carol carol@company.com
David david@company.com
Eve eve@company.com

You can also order by column position:

... ORDER BY 1, 2;   -- order by first column, then second
-- ERROR: ORDER BY allowed only at the end
SELECT name FROM employees_us
ORDER BY name             -- โœ— not allowed inside a set operation
UNION
SELECT name FROM employees_uk;

Operator Precedence

When you combine multiple set operators, evaluation follows these rules:

  1. INTERSECT has higher precedence than UNION and MINUS (Oracle 21c+)
  2. In versions before 21c, all set operators have equal precedence and are evaluated top-to-bottom
  3. Use parentheses to make precedence explicit and your intent clear
-- Ambiguous โ€” relies on default precedence:
SELECT email FROM a
UNION
SELECT email FROM b
INTERSECT
SELECT email FROM c;

-- Clearer โ€” parentheses make intent explicit:
SELECT email FROM a
UNION
(SELECT email FROM b
 INTERSECT
 SELECT email FROM c);
โš 
Always parenthesise mixed set operators. The default precedence rule changed between Oracle versions, so relying on it makes your query version-dependent and easy to misread.

Set Operators vs JOINs

Many queries can be expressed either way. Set operators are usually the clearer choice when:

  • The two SELECTs return the same columns from different sources
  • You want a vertical combination (stacking rows), not a horizontal one
  • You're testing set membership (does this value exist in another set?)
Goal Set Operator JOIN
Customers who placed an order customers INTERSECT (SELECT customer_id FROM orders) customers INNER JOIN orders
Customers without orders customers MINUS (SELECT customer_id FROM orders) LEFT JOIN ... WHERE orders.id IS NULL (anti-join)
Combine archived + current employees current UNION ALL archived Not naturally expressible with JOIN

For "customers without orders", a JOIN with WHERE IS NULL (the anti-join pattern) is usually faster than MINUS because the optimiser can use indexes more efficiently โ€” but MINUS is often easier to read.

Worked Example โ€” Auditing Two Lists

You receive a "current employees" CSV and a "payroll" CSV. Are they in sync?

-- 1. Anyone in payroll but not in employees (orphan payment risk):
SELECT emp_id FROM payroll
MINUS
SELECT emp_id FROM employees;

-- 2. Anyone in employees but not in payroll (unpaid risk):
SELECT emp_id FROM employees
MINUS
SELECT emp_id FROM payroll;

-- 3. Employees correctly in both:
SELECT emp_id FROM employees
INTERSECT
SELECT emp_id FROM payroll;

-- 4. Total unique IDs across both sources:
SELECT emp_id FROM employees
UNION
SELECT emp_id FROM payroll;

This is a classic data reconciliation pattern โ€” set operators express it more directly than the equivalent JOINs.

Performance Notes

Operator What happens Cost
UNION ALL Concatenates rows Cheapest
UNION Concatenates + sorts + deduplicates More expensive than UNION ALL
INTERSECT Sort both queries + match Expensive on large data
MINUS Sort both queries + subtract Expensive on large data

Tips:

  • Push filters (WHERE) inside each SELECT, not after the set operation
  • Add indexes on the columns being matched (especially for INTERSECT and MINUS)
  • Use UNION ALL + DISTINCT only if you specifically want a DISTINCT (rarely faster than UNION)
  • For "exists / not exists" semantics on large tables, anti-joins or EXISTS clauses often beat MINUS
-- Less efficient โ€” filter applied after combining everything
SELECT * FROM (
  SELECT name, dept_id FROM employees_us
  UNION ALL
  SELECT name, dept_id FROM employees_uk
) WHERE dept_id = 50;

-- More efficient โ€” filters push into each query
SELECT name, dept_id FROM employees_us WHERE dept_id = 50
UNION ALL
SELECT name, dept_id FROM employees_uk WHERE dept_id = 50;

Common Errors

Error Cause Fix
ORA-01789: query block has incorrect number of result columns SELECTs have different column counts Make both SELECTs return the same number of columns
ORA-01790: expression must have same datatype as corresponding expression Column N has incompatible types between queries Cast one side: CAST(col AS VARCHAR2(50)) or TO_CHAR(col)
ORA-00904: invalid identifier (in ORDER BY) Ordering by a column name from the second SELECT Use a column name from the first SELECT, or use a positional number like ORDER BY 1
Wrong row counts after UNION UNION (which dedupes) used when you wanted to keep all rows Switch to UNION ALL
Unexpected NULL handling in MINUS NULLs match other NULLs in MINUS/INTERSECT (unlike =) This is correct behaviour; if undesired, add WHERE col IS NOT NULL to both queries
Performance is poor Filter applied after the set operation Push WHERE clauses inside each SELECT
ORA-00933: SQL command not properly ended Trailing semicolon between SELECTs Remove inner semicolons; only the final query gets a ;

Interview Corner

IQ ยท Set Operators
What is the difference between UNION and UNION ALL? When should you prefer each?
โ–ถ Show answer

Short answer: UNION removes duplicates from the combined result; UNION ALL keeps every row.

Cost difference: UNION has to sort the combined data and deduplicate it, which is expensive on large datasets. UNION ALL just concatenates and is essentially free.

When to use UNION:

  • The data sources may overlap and you genuinely need a deduped output
  • You can't easily prove the sets are disjoint

When to use UNION ALL:

  • You know the sets are disjoint (e.g., partitioned data, separate time ranges)
  • Performance matters and duplicates are acceptable
  • You're computing counts and want to preserve them
-- Slow on large tables:
SELECT customer_id FROM orders_2023 UNION SELECT customer_id FROM orders_2024;

-- Fast โ€” years can't overlap so dedup is wasted work:
SELECT customer_id FROM orders_2023 UNION ALL SELECT customer_id FROM orders_2024;

Rule of thumb: Default to UNION ALL. Only reach for UNION when you need deduplication and can't get it more cheaply elsewhere.

IQ ยท Set Operators
How would you find rows present in table A but missing from table B without using MINUS?
โ–ถ Show answer

Three equivalent approaches; the last two are usually faster than MINUS on large tables:

1. MINUS (most readable):

SELECT id FROM a
MINUS
SELECT id FROM b;

2. Anti-join with LEFT JOIN + IS NULL:

SELECT a.id
FROM   a
LEFT JOIN b ON a.id = b.id
WHERE  b.id IS NULL;

3. NOT EXISTS:

SELECT a.id
FROM   a
WHERE  NOT EXISTS (SELECT 1 FROM b WHERE b.id = a.id);

Trade-offs:

  • NOT EXISTS is usually the fastest because Oracle's optimiser can stop at the first match for each a.id.
  • LEFT JOIN ... IS NULL is close in performance and very clear in intent.
  • NOT IN is similar in writing but broken if the inner query returns NULLs โ€” avoid it unless you guarantee NOT NULL.
  • MINUS is clearest but must sort both inputs end-to-end.

For a production interview answer: "MINUS is concise, but I usually use NOT EXISTS for performance and to be NULL-safe."

IQ ยท Set Operators
A query uses INTERSECT but returns fewer rows than expected. What could be wrong?
โ–ถ Show answer

INTERSECT compares entire rows, not just one column. If column 2 differs even slightly between the two queries โ€” different formatting, trailing spaces, different cases, type differences โ€” the row won't intersect.

Common causes:

  1. Trailing whitespace โ€” 'Alice' vs 'Alice ' (CHAR vs VARCHAR2 padding)
  2. Case differences โ€” 'alice@x.com' vs 'Alice@x.com'
  3. Hidden columns added โ€” extra columns added by mistake to one SELECT
  4. Different numeric scales โ€” 100 vs 100.00 (these intersect, but 100.00 vs 100.000000001 do not)
  5. Date vs Timestamp โ€” DATE '2024-01-01' vs TIMESTAMP '2024-01-01 00:00:00' may not match depending on column types

Debug strategy: rewrite as an explicit join with = predicates and check which rows fail. Or apply TRIM() / UPPER() / TO_CHAR(date, 'YYYY-MM-DD') to normalise both sides before intersecting.

-- Normalised intersect
SELECT UPPER(TRIM(email)) FROM list_a
INTERSECT
SELECT UPPER(TRIM(email)) FROM list_b;

Related Topics

  • Subqueries โ€” set operators are often combined with subqueries in WHERE clauses
  • Joins โ€” anti-joins (LEFT JOIN ... WHERE IS NULL) are usually faster than MINUS
  • Aggregate Functions โ€” use GROUP BY inside each SELECT before UNION-ing the aggregated results
  • CTEs & Procedures โ€” extract complex UNION/INTERSECT logic into a CTE for readability
  • Performance โ€” set operators trigger sort-merge plans; check execution plans on large datasets