Set operations

Two result sets meet at a junction, and the only real question is which rows survive the meeting.

The idea

Most SQL compares columns. UNION, INTERSECT and EXCEPT compare whole rows between two queries that already have the same shape: same number of columns, in the same order, with compatible types.

You get four moves: stack the two streams and keep every copy (UNION ALL), stack them and collapse repeats (UNION), keep only what appears in both (INTERSECT), or take the second list away from the first (EXCEPT). Three of those four quietly deduplicate. Only UNION ALL leaves your rows alone.

Watch the junction

operator

playback

inputs: click a value to change it

warehouses
stores

the same two inputs, every operator

the statement you are watching


  

How it works

Both queries are evaluated, then the rows are combined positionally: the first column of the left query lines up with the first column of the right query, whatever they are called. The result takes its column names from the first query only. UNION, INTERSECT and EXCEPT then apply DISTINCT to the answer; adding ALL keeps every copy (widely supported for UNION ALL, and in PostgreSQL also for INTERSECT ALL / EXCEPT ALL).

-- warehouses.city : lisbon, oslo, porto, oslo   (4 rows)
-- stores.town     : oslo, porto, quito, reykjavik (4 rows)

SELECT city AS location FROM warehouses
UNION ALL                     -- stack, compare nothing        -> 8 rows
SELECT town FROM stores;

SELECT city AS location FROM warehouses
UNION                         -- stack, then DISTINCT          -> 5 rows
SELECT town FROM stores;

SELECT city AS location FROM warehouses
INTERSECT                     -- distinct rows in both         -> 2 rows
SELECT town FROM stores;

SELECT city AS location FROM warehouses
EXCEPT                        -- distinct left rows not in right -> 1 row (lisbon)
SELECT town FROM stores;

SELECT town FROM stores
EXCEPT                        -- the other direction           -> 2 rows (quito, reykjavik)
SELECT city FROM warehouses;

-- ORDER BY belongs to the whole statement, at the very end,
-- and can only see the FIRST query's column names.
SELECT city AS location FROM warehouses
UNION
SELECT town FROM stores
ORDER BY location;

A useful mental model: UNION ALL is concatenation, UNION is concatenation plus a hash table, and INTERSECT / EXCEPT build a hash of one side and then probe it with the other.

Cost

With m rows on the left and n on the right, and d distinct result rows.

operationtimeextra spacewhat it pays for
UNION ALLO(m + n)O(1)nothing: it can stream
UNIONO(m + n) hash
O(k log k) sorted
O(d)one dedup pass over everything
INTERSECTO(m + n) hashO(min(m, n))a hash of the smaller side
EXCEPTO(m + n) hashO(n)a hash of the right side, then dedup

Trade-off: deduplication is never free. It needs a sort or a hash table sized to the distinct rows, and it blocks streaming. If you already know the inputs cannot overlap (monthly partitions, disjoint regions), UNION ALL gives the same answer for less.

Watch out for

Worked example

You hold two extracts: warehouses(city) with lisbon, oslo, porto, oslo (two sites in oslo) and stores(town) with oslo, porto, quito, reykjavik. Ops asks: where do we have a store but no warehouse?

That is SELECT town FROM stores EXCEPT SELECT city FROM warehouses → quito, reykjavik. Put the queries the other way round and you have answered a different question, warehouse cities with no store, and get lisbon. Same two tables, same operator, different direction, different answer: that is the whole point of the swap control above.

Now a second ask: how many sites do we have per city? Reaching for UNION would quietly delete the second oslo warehouse. You want SELECT city AS location FROM warehouses UNION ALL SELECT town FROM stores wrapped in a GROUP BY location, which returns oslo = 3, porto = 2, and the rest 1: eight rows in, five groups out.

Check yourself

warehouses = lisbon, oslo, porto, oslo. stores = oslo, porto, quito, reykjavik. How many rows come back from UNION ALL, and from UNION?

What happens with SELECT city AS location FROM warehouses UNION SELECT town AS place FROM stores ORDER BY place;?

Try this: run UNION ALL to the end, then click UNION and watch three rows fade. The counter falls by exactly the number of duplicate copies, never by more.