Set Operations
Set operators combine the result sets of two or more queries. T-SQL supports UNION, UNION ALL, INTERSECT, and EXCEPT. All four require the inputs to have the same number of columns and compatible data types.
UNION vs UNION ALL
| Operator | Duplicates | Sort/Distinct cost |
|---|---|---|
UNION ALL |
Kept | None — cheap |
UNION |
Removed | Implicit DISTINCT (sort or hash) — expensive |
-- Quick concatenation (no dedupe needed)
SELECT employee_id, 'EMP' AS source FROM employees
UNION ALL
SELECT contractor_id, 'CON' FROM contractors;
-- DISTINCT cleanup (slower)
SELECT email FROM employees
UNION
SELECT email FROM customers;
UNION ALL by default. Only switch to UNION when duplicates are genuinely possible AND you need them removed. Many a slow query has been fixed by changing UNION to UNION ALL.
INTERSECT
Returns rows present in both inputs (deduplicated).
-- Email addresses that belong to BOTH an employee and a customer
SELECT email FROM employees
INTERSECT
SELECT email FROM customers;
EXCEPT
Returns rows in the first input that are not in the second (deduplicated). The MS T-SQL spelling of MINUS.
-- Employees who are NOT also customers
SELECT email FROM employees
EXCEPT
SELECT email FROM customers;
EXCEPT is great for diffing two snapshots:
-- What rows are in current but not in archive?
SELECT * FROM employees_current
EXCEPT
SELECT * FROM employees_archive;
-- What rows are in archive but not in current (deletions)?
SELECT * FROM employees_archive
EXCEPT
SELECT * FROM employees_current;
Column Compatibility
The columns in each branch must align by position and have compatible types. Names come from the first SELECT.
-- The output columns are named id and label
SELECT employee_id AS id, first_name AS label FROM employees
UNION ALL
SELECT department_id, department_name FROM departments;
ORDER BY at the End
ORDER BY applies to the combined result, not to individual branches. Place it after the final SELECT:
SELECT customer_name AS party FROM customers
UNION
SELECT first_name + ' ' + last_name FROM employees
ORDER BY party;
NULL Semantics in Set Operations
Unlike WHERE, set operators treat NULL = NULL as TRUE for the purpose of comparing rows. Two rows that are NULL in matching columns are considered duplicates by UNION, equal by INTERSECT, and "matched" by EXCEPT.
-- These two queries return one row each, demonstrating NULL=NULL in set ops
SELECT NULL AS x
INTERSECT
SELECT NULL; -- 1 row
SELECT NULL AS x
EXCEPT
SELECT NULL; -- 0 rows
This is why EXCEPT works as a robust table-diff tool — WHERE a.col = b.col would miss NULL/NULL matches.
Combining Multiple Set Operators
Precedence: INTERSECT binds tighter than UNION/EXCEPT. Use parentheses to be explicit:
-- Safer, explicit version
(SELECT email FROM employees
INTERSECT
SELECT email FROM customers)
UNION
SELECT email FROM partners;
Common Pitfalls
- Hidden DISTINCT cost:
UNIONmaterializes a sort or hash; on large result sets this dominates. - Column count mismatch: Adding a new column to one side without adjusting the other breaks the query immediately at parse time.
- Implicit conversions: If types don't match exactly, SQL Server coerces — and one side may suffer a silent precision loss.
- ORDER BY in branches: You can't put
ORDER BYin any branch except by wrapping it inSELECT ... FROM (... ORDER BY ... OFFSET 0 ROWS) sub.
-- ERROR: ORDER BY only allowed on final query
SELECT TOP 10 * FROM employees ORDER BY hire_date -- not allowed here
UNION ALL
SELECT TOP 10 * FROM contractors;
-- Workaround: wrap each branch as a subquery
SELECT * FROM (
SELECT TOP 10 * FROM employees ORDER BY hire_date
) e
UNION ALL
SELECT * FROM (
SELECT TOP 10 * FROM contractors ORDER BY hire_date
) c;
Best Practices
- Default to
UNION ALL— only useUNIONwhen dedupe is required. - Use
EXCEPTto diff two row sets including NULL columns. - Wrap with parentheses when mixing
UNIONandINTERSECTto control precedence. - Watch for type and column-count mismatches when refactoring either branch.
Summary
UNION ALLis fast (no dedupe);UNIONadds a DISTINCT step.INTERSECTreturns rows in both inputs;EXCEPTreturns rows in the first but not the second.- Set operators treat NULL = NULL as equal, unlike standard
=. - Output column names come from the first SELECT;
ORDER BYapplies once at the end.