UNION, INTERSECT, EXCEPT
A JOIN adds columns — it makes the result wider. A set operation adds rows — it makes the result taller. That one sentence is the whole chapter; everything else is detail.
UNION vs UNION ALL
Both stack the second result under the first. The difference is duplicates.
-- UNION ALL: keeps everything, including duplicates SELECT city FROM students UNION ALL SELECT city FROM students; -- 20 rows -- UNION: removes duplicate ROWS SELECT city FROM students UNION SELECT city FROM students; -- 7 rows — the 6 distinct cities plus NULL
Result 1
| city |
|---|
| Bukhara |
| Fergana |
| Samarkand |
| Samarkand |
| Namangan |
| Samarkand |
| Karshi |
| Tashkent |
| Bukhara |
| Andijan |
| Karshi |
| Tashkent |
| Namangan |
| Andijan |
| Namangan |
… first 15 of 600 rows
600 rows
Result 2
| city |
|---|
| None |
| Andijan |
| Bukhara |
| Fergana |
| Karshi |
| Namangan |
| Nukus |
| Samarkand |
| Tashkent |
9 rows
1 ms
A realistic use — one combined directory of everybody in the school:
SELECT first_name || ' ' || last_name AS name, 'student' AS role, city AS extra FROM students UNION ALL SELECT full_name, 'teacher', subject FROM teachers ORDER BY role, name;
| name | role | extra |
|---|---|---|
| Akmal Alimov | student | Tashkent |
| Akmal Alimov | student | Tashkent |
| Akmal Ergashev | student | None |
| Akmal Karimov | student | Fergana |
| Akmal Nazarov | student | Andijan |
| Akmal Qodirov | student | Samarkand |
| Akmal Rahimov | student | Samarkand |
| Akmal Saidov | student | Fergana |
| Akmal Saidov | student | Namangan |
| Akmal Umarov | student | Samarkand |
| Akmal Umarov | student | Fergana |
| Akmal Yusupov | student | Bukhara |
| Akmal Yusupov | student | Andijan |
| Aziz Alimov | student | Tashkent |
| Aziz Ergashev | student | Bukhara |
… first 15 of 312 rows
312 rows
1 ms
| name | role | extra |
|---|---|---|
| Aziza Karimova | student | Tashkent |
| Bekzod Toshev | student | Samarkand |
| … | … | … |
| Kamola Ergasheva | teacher | Statistics |
| Nodira Alimova | teacher | Data Science |
| Rustam Halilov | teacher | Databases |
| Sardor Yoqubov | teacher | Programming |
Note the added literal column 'student' / 'teacher'. Without it you could not tell the two groups apart afterwards — always tag the source when you stack different things together.
UNION must sort or hash the entire combined result to find duplicates — on large tables that is expensive. UNION ALL just concatenates and is far faster. Use plain UNION only when duplicates are genuinely possible and genuinely unwanted. Students reach for UNION by habit; make UNION ALL the default and justify the other.
Two rows are duplicates only if every column matches. Add one differing column and nothing is removed. And just like DISTINCT (Chapter 2), UNION treats all NULLs as the same value — the one place SQL breaks its own NULL rule.
The rules
- Same number of columns in every branch — otherwise it is an error.
- Compatible types, position by position. Column 1 of the first query lines up with column 1 of the second regardless of their names.
- Column names come from the first query. Aliases in later branches are ignored.
- One
ORDER BY, at the very end, applying to the combined result.
-- ERROR: 2 columns vs 3 SELECT first_name, city FROM students UNION ALL SELECT full_name, subject, salary FROM teachers; -- Fixed: pad the shorter branch with a literal so the shapes match SELECT first_name, city, NULL AS salary FROM students UNION ALL SELECT full_name, subject, salary FROM teachers; -- ORDER BY belongs to the whole result, so it goes last, once SELECT first_name AS name FROM students UNION ALL SELECT full_name FROM teachers ORDER BY name;
Nothing stops you from writing SELECT first_name, city above SELECT subject, full_name — the types line up, so it runs, and you get names mixed into the city column. SQL matches by position only. Always list the columns explicitly in the same order in every branch, and never use SELECT * in a set operation.
INTERSECT and EXCEPT
INTERSECT returns rows present in both results. EXCEPT (called MINUS in Oracle) returns rows in the first that are not in the second. Both remove duplicates.
-- Students taking BOTH Python Basics (1) AND SQL for Analytics (2) SELECT student_id FROM enrollments WHERE course_id = 1 INTERSECT SELECT student_id FROM enrollments WHERE course_id = 2; -- 1 (Aziza), 4 (Jasur) -- Students taking Python Basics but NOT SQL for Analytics SELECT student_id FROM enrollments WHERE course_id = 1 EXCEPT SELECT student_id FROM enrollments WHERE course_id = 2; -- 2 (Bekzod), 7 (Sevara), 10 (Ulugbek)
Result 1
| student_id |
|---|
| 3 |
| 8 |
| 12 |
| 24 |
| 26 |
| 28 |
| 33 |
| 34 |
| 35 |
| 56 |
| 59 |
| 62 |
| 63 |
| 69 |
| 73 |
… first 15 of 60 rows
60 rows
Result 2
| student_id |
|---|
| 6 |
| 10 |
| 11 |
| 13 |
| 14 |
| 19 |
| 20 |
| 21 |
| 22 |
| 23 |
| 27 |
| 30 |
| 32 |
| 38 |
| 43 |
… first 15 of 74 rows
74 rows
1 ms
Check it by hand — this is exactly why the sample data is small:
| Course 1 students | Course 2 students | INTERSECT | EXCEPT (1 minus 2) |
|---|---|---|---|
| 1, 2, 4, 7, 10 | 1, 3, 4, 6, 8 | 1, 4 | 2, 7, 10 |
Chapter 5's "courses nobody enrolled in" can be written three ways, and this is the shortest:
SELECT course_id FROM courses EXCEPT SELECT course_id FROM enrollments; -- 6
| course_id |
|---|
| 12 |
1 row
0 ms
It is also NULL-safe, unlike NOT IN — EXCEPT treats NULLs as equal, so it does not blow up the way Chapter 3's trap does. The limitation: it can only return the columns being compared, so if you need the course title too you are back to a LEFT JOIN or NOT EXISTS.
INTERSECT is also the cleanest way to express "has all of these", which is otherwise awkward:
-- Students enrolled in courses 1 AND 2 AND 5 SELECT student_id FROM enrollments WHERE course_id = 1 INTERSECT SELECT student_id FROM enrollments WHERE course_id = 2 INTERSECT SELECT student_id FROM enrollments WHERE course_id = 5; -- 1 (Aziza)
| student_id |
|---|
| 3 |
| 24 |
| 26 |
| 35 |
| 56 |
| 59 |
| 63 |
| 69 |
| 73 |
| 92 |
| 105 |
| 125 |
| 174 |
| 176 |
| 184 |
… first 15 of 29 rows
29 rows
0 ms
SQLite and PostgreSQL support INTERSECT and EXCEPT. MySQL only added them in 8.0.31; before that you must emulate them with INNER JOIN (intersect) and LEFT JOIN ... IS NULL (except). Oracle spells EXCEPT as MINUS.
Set operations vs JOIN
| JOIN | Set operation | |
|---|---|---|
| Combines | Columns — result gets wider | Rows — result gets taller |
| Needs | A matching condition (ON) |
Same column count and types |
| Duplicates | Can multiply rows (fan-out) | UNION/INTERSECT/EXCEPT remove them |
| NULL handling | NULL = NULL is UNKNOWN, never matches |
NULLs are treated as equal |
| Typical use | "Give me the student's name with this enrollment" | "Stack this year's and last year's data" |
The NULL row is the subtle one, and it is the reason EXCEPT succeeds where NOT IN fails.
Real uses
1. Combining data split across tables or periods
Archived data, monthly partitions, two systems being merged — all are UNION ALL:
SELECT * FROM enrollments_2025 UNION ALL SELECT * FROM enrollments_2026;
2. Adding a totals row to a report
SELECT c.title, COUNT(*) AS enrollments FROM enrollments e JOIN courses c USING (course_id) GROUP BY c.course_id, c.title UNION ALL SELECT 'TOTAL', COUNT(*) FROM enrollments ORDER BY (title = 'TOTAL'), enrollments DESC;
TOTAL20
The ORDER BY (title = 'TOTAL') trick sorts FALSE (0) before TRUE (1), pinning the total to the bottom.
3. Unpivot — turning columns into rows
CASE pivots rows into columns (Chapter 8); UNION ALL does the reverse. This shape is exactly what Seaborn wants — long format — and it is the bridge to Part II of this guide:
SELECT title, 'price' AS metric, price AS value FROM courses UNION ALL SELECT title, 'credits', credits FROM courses ORDER BY title, metric;
| title | metric | value |
|---|---|---|
| Data Visualization | credits | 3 |
| Data Visualization | price | 900 000 |
| Deep Learning | credits | 5 |
| Deep Learning | price | 1 550 000 |
| Excel for Business | credits | 2 |
| Excel for Business | price | 1 100 000 |
| Financial Analysis | credits | 3 |
| Financial Analysis | price | 1 500 000 |
| Graphic Design | credits | 3 |
| Graphic Design | price | 950 000 |
| Java Basics | credits | 4 |
| Java Basics | price | 1 150 000 |
| Linear Algebra | credits | 4 |
| Linear Algebra | price | 1 200 000 |
| Machine Learning | credits | 5 |
… first 15 of 30 rows
30 rows
0 ms
SELECT c.title, e.enrollment_id FROM courses c LEFT JOIN enrollments e ON c.course_id = e.course_id UNION SELECT c.title, e.enrollment_id FROM enrollments e LEFT JOIN courses c ON c.course_id = e.course_id;
| title | enrollment_id |
|---|---|
| Data Visualization | 5 |
| Data Visualization | 30 |
| Data Visualization | 35 |
| Data Visualization | 47 |
| Data Visualization | 86 |
| Data Visualization | 90 |
| Data Visualization | 102 |
| Data Visualization | 107 |
| Data Visualization | 108 |
| Data Visualization | 124 |
| Data Visualization | 144 |
| Data Visualization | 146 |
| Data Visualization | 164 |
| Data Visualization | 171 |
| Data Visualization | 173 |
… first 15 of 2501 rows
2501 rows
3 ms
Here plain UNION is correct, not UNION ALL — the matched rows appear in both halves and must be deduplicated.
Practice set
-- 1. One directory of students and teachers SELECT first_name || ' ' || last_name AS name, 'student' AS role FROM students UNION ALL SELECT full_name, 'teacher' FROM teachers ORDER BY role, name; -- 2. Students taking both course 1 and course 2 SELECT student_id FROM enrollments WHERE course_id = 1 INTERSECT SELECT student_id FROM enrollments WHERE course_id = 2; -- 3. Courses nobody enrolled in SELECT course_id FROM courses EXCEPT SELECT course_id FROM enrollments; -- 4. All distinct cities and subjects in one column SELECT city AS value, 'city' AS kind FROM students WHERE city IS NOT NULL UNION SELECT subject, 'subject' FROM teachers ORDER BY kind, value; -- 5. Course list with a TOTAL row SELECT c.title, COUNT(*) AS n FROM enrollments e JOIN courses c USING (course_id) GROUP BY c.course_id, c.title UNION ALL SELECT 'TOTAL', COUNT(*) FROM enrollments ORDER BY (title = 'TOTAL'), n DESC; -- 6. Unpivot course metrics into long format (Seaborn-ready) SELECT title, 'price' AS metric, price AS value FROM courses UNION ALL SELECT title, 'credits', credits FROM courses; -- 7. Students who have an active enrollment but never a completed one SELECT student_id FROM enrollments WHERE status = 'active' EXCEPT SELECT student_id FROM enrollments WHERE status = 'completed'; -- 8. Prove UNION vs UNION ALL to yourself SELECT COUNT(*) FROM (SELECT city FROM students UNION ALL SELECT city FROM students); -- 20 SELECT COUNT(*) FROM (SELECT city FROM students UNION SELECT city FROM students); -- 7
Interview questions
Q: UNION vs UNION ALL?
UNION removes duplicate rows and therefore has to sort or hash the whole result; UNION ALL keeps everything and is much faster. Prefer UNION ALL unless deduplication is actually needed.
Q: UNION vs JOIN?
UNION stacks rows vertically and requires matching column counts and types. JOIN combines columns horizontally and requires a matching condition. Wider vs taller.
Q: What are the requirements for a UNION?
Same number of columns in each branch, compatible types matched by position, and a single ORDER BY at the end. Column names come from the first branch.
Q: What do INTERSECT and EXCEPT do?
INTERSECT returns rows present in both results; EXCEPT returns rows in the first that are absent from the second. Both deduplicate. Oracle calls EXCEPT MINUS; MySQL only got them in 8.0.31.
Q: Why might EXCEPT be safer than NOT IN?
Set operations treat NULLs as equal, so a NULL in the second result does not wipe out the answer the way it does with NOT IN.
Q: How do you add a totals row to a grouped report?
UNION ALL a second query that computes the overall total with a literal label, then sort so the label lands last. (Some databases also offer GROUP BY ROLLUP.)
Q: How would you emulate FULL OUTER JOIN?
A LEFT JOIN B UNION B LEFT JOIN A — with plain UNION, so the rows that matched in both halves are deduplicated.
Summary
- JOIN makes results wider (columns); set operations make them taller (rows).
UNION ALLkeeps duplicates and is fast — make it the default.UNIONdeduplicates and costs a sort.- Rules: same column count, compatible types by position not name, names from the first branch, one
ORDER BYat the end. - Pad a shorter branch with
NULL AS colto make the shapes match, and tag each branch with a literal so you can tell the sources apart. INTERSECT= in both.EXCEPT= in the first only. Both deduplicate and both treat NULLs as equal, which makesEXCEPTa NULL-safe anti-join.- Real uses: stacking periods or archives, adding a TOTAL row, unpivoting to long format for plotting, and emulating
FULL OUTER JOIN.
Next: Chapter 10 — creating and changing data: CREATE, INSERT, UPDATE, DELETE, constraints, transactions and normalization.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.