The Story That Explains Set Operators
• Everyone invited — combine both lists, but invite no one twice. That is UNION.
• Friends of both — people who appear on both lists. That is INTERSECT.
• Only the bride's people — on her list but not the groom's. That is EXCEPT.
A relational table is just a list of rows. Set operators let you combine two result sets the same way you would combine two guest lists — by row, treating each row as a member of a set.
This tutorial walks through the six tools every database practitioner must master to combine and accelerate queries: the three set operators (UNION, INTERSECT, EXCEPT), plus Views, Indexes, and the three families of Joins (Inner, Outer, Self). Every concept is grounded in one small, consistent dataset so you can trace each result by hand.
Set operators stack results vertically (row on top of row, same columns). Joins stitch results horizontally (columns from one table beside columns from another, matched by a key). Confusing the two is the single most common beginner mistake.
The Sample Database We Will Use
Every example below uses these tables. Keep them in view — tracing results by hand is how the operators truly click.
| cust_id | name |
|---|---|
| 1 | Asha |
| 2 | Bilal |
| 3 | Chen |
| 4 | Diya |
| cust_id | name |
|---|---|
| 3 | Chen |
| 4 | Diya |
| 5 | Esha |
| 6 | Farid |
| employees | emp_id | name | dept_id | manager_id | salary |
|---|---|---|---|---|---|
| 101 | Asha | 10 | NULL | 95000 | |
| 102 | Bilal | 10 | 101 | 62000 | |
| 103 | Chen | 20 | 101 | 58000 | |
| 104 | Diya | 30 | 102 | 47000 | |
| 105 | Esha | NULL | 102 | 51000 |
| departments | dept_id | dept_name |
|---|---|---|
| 10 | Engineering | |
| 20 | Sales | |
| 30 | Marketing | |
| 40 | Research |
Asha is a manager with NULL manager. Esha has a NULL dept_id
(no department). Department 40 (Research) has no employees.
These gaps are intentional — they are exactly what reveals the difference between
INNER and OUTER joins later.
Set Operators — The Animated Venn Diagram
All three set operators act on two result sets that must be union-compatible: same number of columns, in the same order, with compatible data types. The animated diagram below cycles through what each operator keeps, using our two buyer lists.
The blue circle is online_buyers; the red circle is store_buyers. The shaded region is the rows each operator returns.
UNION — Combine and Deduplicate
UNION stacks two result sets and removes duplicate rows. UNION ALL does the same but keeps duplicates — and is much faster because it skips the deduplication step.
-- Every customer who bought through any channel
SELECT cust_id, name FROM online_buyers
UNION
SELECT cust_id, name FROM store_buyers
ORDER BY cust_id;
An ORDER BY applies to the whole combined result and must appear
after the final SELECT, not on the individual queries. Putting it on the first
SELECT is a syntax error in standard SQL.
INTERSECT — Rows Common to Both
INTERSECT returns only rows that appear in both result sets — our "friends of both" from the wedding story. Duplicates are removed automatically.
-- Customers who bought BOTH online and in store
SELECT cust_id, name FROM online_buyers
INTERSECT
SELECT cust_id, name FROM store_buyers;
Older MySQL versions (before 8.0.31) have no INTERSECT. You can reproduce it with an
INNER JOIN on every column, or with IN /
EXISTS on a subquery. The set-operator form is clearer when it is available.
EXCEPT — In One Set but Not the Other
EXCEPT (called MINUS in Oracle) returns rows from the first
query that are not present in the second. Order matters: A EXCEPT B is
different from B EXCEPT A.
| cust_id | name |
|---|---|
| 1 | Asha |
| 2 | Bilal |
| cust_id | name |
|---|---|
| 5 | Esha |
| 6 | Farid |
-- Online-only customers (never bought in store)
SELECT cust_id, name FROM online_buyers
EXCEPT
SELECT cust_id, name FROM store_buyers;
-- Oracle uses MINUS instead of EXCEPT:
-- SELECT cust_id FROM online_buyers MINUS SELECT cust_id FROM store_buyers;
Swap the two queries around UNION or INTERSECT and the answer is identical. Swap them around EXCEPT and you get a completely different set. Always read it as "keep the left, subtract the right."
Set Operator Rules at a Glance
| Operator | Returns | Removes Duplicates? | Order Matters? | Oracle Name |
|---|---|---|---|---|
UNION | All rows from both | Yes | No | UNION |
UNION ALL | All rows from both | No | No | UNION ALL |
INTERSECT | Rows in both | Yes | No | INTERSECT |
EXCEPT | Left minus right | Yes | Yes | MINUS |
ORDER BY goes last, applying to the whole result.
Views — A Saved Query That Acts Like a Table
Green dots show that a view holds no data of its own — it pulls live rows from its base tables each time it is queried.
-- Create a reusable view joining employees to their department
CREATE VIEW staff_dirs AS
SELECT e.name, d.dept_name, e.salary
FROM employees e
JOIN departments d ON e.dept_id = d.dept_id;
-- Now query it like an ordinary table
SELECT name, dept_name
FROM staff_dirs
WHERE salary > 50000;
A normal view stores no data — it re-runs its query every time, always fresh but never faster. A materialized view physically stores the result and must be refreshed; it trades freshness for speed on expensive aggregations.
Indexes — The Speed Mechanism
Top: the engine walks every row in turn. Bottom: the index lets it land on the target row immediately.
-- Create an index on a frequently-filtered column
CREATE INDEX idx_emp_dept ON employees(dept_id);
-- A composite (multi-column) index — order matters!
CREATE INDEX idx_emp_dept_salary ON employees(dept_id, salary);
-- Enforce uniqueness AND speed lookups at once
CREATE UNIQUE INDEX idx_emp_email ON employees(email);
| Scenario |
|---|
| WHERE on the column |
| JOIN keys |
| ORDER BY / GROUP BY |
| High-selectivity columns |
| Scenario |
|---|
| Heavy INSERT / UPDATE load |
| Tiny tables |
| Low-selectivity (e.g. gender) |
| Rarely-queried columns |
Every index must be updated on every INSERT, UPDATE, and
DELETE, and it consumes disk space. Indexing every column "to be safe"
slows writes and wastes storage. Index the columns you actually filter, join, or sort on.
An index on (dept_id, salary) can serve queries filtering on
dept_id alone, or on dept_id AND salary — but
not on salary alone. The leftmost column must be used for the
index to apply.
Joins — Stitching Tables Side by Side
Where set operators stack rows vertically, a join matches rows from two tables on a related column and merges their columns into one wider row. The diagram below shows the four families and exactly which rows survive each one.
Blue = left table, red = right table. Green shading = rows the join keeps.
INNER JOIN — Only Matching Rows
An INNER JOIN returns only rows where the join key exists in both tables. Watch the animation below "draw" the matches between employees and departments.
Solid green links are kept by INNER JOIN. Esha (dept NULL) and Research (no staff) are dashed — INNER JOIN drops both.
SELECT e.name, d.dept_name
FROM employees e
INNER JOIN departments d ON e.dept_id = d.dept_id;
OUTER JOINs — Keep the Unmatched
Outer joins preserve rows that have no match, filling the missing side with
NULL. There are three flavours.
-- LEFT: every employee, even those with no department
SELECT e.name, d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id;
-- FULL: every employee AND every department
SELECT e.name, d.dept_name
FROM employees e
FULL OUTER JOIN departments d ON e.dept_id = d.dept_id;
| name | dept_name |
|---|---|
| Asha | Engineering |
| Bilal | Engineering |
| Chen | Sales |
| Diya | Marketing |
| Esha | NULL |
| name | dept_name |
|---|---|
| Asha | Engineering |
| Bilal | Engineering |
| Chen | Sales |
| Diya | Marketing |
| NULL | Research |
To find rows with no match — departments with no employees, customers with no
orders — use a LEFT JOIN then filter
WHERE right_table.key IS NULL. This is the everyday anti-join pattern.
SELF JOIN — A Table Joined to Itself
employees table,
a manager is an employee — manager_id points to another
emp_id in the same table. To list each person beside their manager's name,
we join employees to a second copy of itself, using two aliases.
Each worker row (left) links to the row describing its manager (right) — both drawn from the very same table.
-- List each employee next to their manager's name
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.emp_id;
Because the same table appears twice, you must give each instance a distinct
alias (here e and m). Without aliases the database cannot tell which
copy a column refers to, and the query is ambiguous.
Joins vs Set Operators — Side by Side
| Property | Set Operators | Joins |
|---|---|---|
| Direction of combination | Vertical (stack rows) | Horizontal (widen rows) |
| Column requirement | Same count & types both sides | Any columns; matched on a key |
| Result width | Same as input columns | Sum of both tables' columns |
| Matches on | Whole-row equality | A specified ON condition |
| Typical question | "In which list(s)?" | "What related detail?" |
Practice Problems
Try each before revealing the logic. Use the sample tables from Section 02.
store_buyers EXCEPT online_buyers.
e.manager_id = m.emp_id.
dept_id then sorting by salary. → Composite index (dept_id, salary).
-- Q3 solution: store-only customers
SELECT cust_id, name FROM store_buyers
EXCEPT
SELECT cust_id, name FROM online_buyers;
-- Q4 solution: all departments, staffed or not
SELECT d.dept_name, e.name
FROM departments d
LEFT JOIN employees e ON d.dept_id = e.dept_id;
Golden Rules
UNION ALL over UNION whenever duplicates are
impossible or wanted — it skips an expensive deduplication step.
(a, b) helps
queries on a or a AND b, but never on b alone.
WHERE right.key IS NULL.