Skip to content

📘 08-03: Recursive Queries & Set Operators

SQL & Databases

Module 08: Advanced SQL

Home All Notes Practice Quiz

💡 Finishing What Module 05 Started

05-04 ended with a teaser: a WITH RECURSIVE query that walked one employee up their management chain. This note gives recursive CTEs full treatment — walking both up and down a hierarchy, with a guard against infinite loops — and then covers SQL's set operators, which combine the results of separate queries the way JOIN combines columns from separate tables.


📌 WITH RECURSIVE, Fully Worked

Every recursive CTE has the same two-part shape:

WITH RECURSIVE cte_name AS (
    -- 1. Anchor member: the non-recursive starting point
    SELECT ...

    UNION ALL

    -- 2. Recursive member: references cte_name itself
    SELECT ...
    FROM   some_table
    JOIN   cte_name ON <link the recursive step back to the anchor's growing result>
)
SELECT * FROM cte_name;

The engine runs the anchor once, then repeatedly re-runs the recursive member — feeding it the rows produced in the previous iteration — until an iteration produces zero new rows, at which point it stops.

Walking down: everyone who reports to a given manager

The 05-04 teaser walked up the chain (an employee's chain of supervisors). Walking down — every employee who reports to a given manager, directly or indirectly — uses the same shape with the join direction reversed:

WITH RECURSIVE org_chart AS (
    -- anchor: start at the top manager
    SELECT employee_number, first_name, last_name, supervisor_id, 1 AS level
    FROM   employee
    WHERE  employee_number = 100   -- e.g., the VP of Engineering

    UNION ALL

    -- recursive step: find everyone whose supervisor is already in org_chart
    SELECT e.employee_number, e.first_name, e.last_name, e.supervisor_id,
           oc.level + 1
    FROM   employee AS e
    JOIN   org_chart AS oc ON e.supervisor_id = oc.employee_number
)
SELECT * FROM org_chart ORDER BY level, last_name;

Each pass finds employees whose supervisor was added in the previous pass, so level grows by one with each layer of the org chart, and the recursion naturally terminates once a pass finds nobody new.

Guarding against infinite loops

A recursive CTE with a genuine cycle in the data (a data-entry error where employee A supervises B, and B ends up listed as A's supervisor) will loop forever unless you cap it. Add an explicit depth guard in the recursive member:

WITH RECURSIVE org_chart AS (
    SELECT employee_number, first_name, last_name, supervisor_id, 1 AS level
    FROM   employee
    WHERE  employee_number = 100

    UNION ALL

    SELECT e.employee_number, e.first_name, e.last_name, e.supervisor_id,
           oc.level + 1
    FROM   employee AS e
    JOIN   org_chart AS oc ON e.supervisor_id = oc.employee_number
    WHERE  oc.level < 20   -- hard stop: no org chart is 20 levels deep
)
SELECT * FROM org_chart ORDER BY level, last_name;

Tip

PostgreSQL also lets you cap total iterations with SET recursion_limit behavior indirectly via a WHERE guard like above — there is no separate "max recursion" session setting in Postgres (unlike SQL Server's OPTION (MAXRECURSION n)). Always design the depth/cycle guard into the query itself, don't rely on the engine to catch it for you.

A bill-of-materials hierarchy (a PART that contains sub-PARTs, which contain further sub-parts) is the other classic recursive-CTE use case — structurally identical to the org chart, just walking a parent/child part_id / parent_part_id relationship instead of employee_number / supervisor_id. Project 08 asks you to build exactly this kind of structure.


📌 Set Operators

Set operators combine the result rows of two or more separate SELECT statements — unlike a JOIN, which combines columns from different tables on a matching condition. Every set operator requires both queries to return the same number of columns, in the same order, with compatible data types.

Operator Meaning Venn-diagram equivalent
UNION All distinct rows from either query A ∪ B (duplicates removed)
UNION ALL All rows from either query, duplicates kept A ∪ B (no dedup — faster)
INTERSECT Only rows appearing in both queries A ∩ B
EXCEPT Rows in the first query not in the second A − B

UNION vs. UNION ALL

-- Every distinct city that is either a customer's city or an employee's city
SELECT city FROM customer
UNION
SELECT city FROM employee;
-- Same query, but keep duplicate cities (much cheaper — no dedup pass)
SELECT city FROM customer
UNION ALL
SELECT city FROM employee;

Always reach for UNION ALL unless you specifically need duplicates removed — UNION's deduplication step means comparing every row against every other row, which costs real performance on large result sets.

INTERSECT — rows in both

-- Employees who are also customers (matched by email address, say)
SELECT email_address FROM employee
INTERSECT
SELECT email_address FROM customer;

EXCEPT — rows in the first, not the second

-- Departments that exist but currently have zero employees
SELECT department_id FROM department
EXCEPT
SELECT department_id FROM employee;

This is a set-operator alternative to the NOT EXISTS anti-join pattern from 08-02 — both answer "what's in A but not B," just phrased differently.

Note

Dialect differences matter here. SQL Server has supported UNION, INTERSECT, and EXCEPT since SQL Server 2005. Oracle uses MINUS instead of EXCEPT for set difference (INTERSECT is spelled the same). MySQL did not support INTERSECT or EXCEPT until version 8.0.31 (released October 2022) — before that, MySQL developers had to fake INTERSECT with an INNER JOIN or EXISTS, and fake EXCEPT/MINUS with NOT EXISTS or a LEFT JOIN ... WHERE right.key IS NULL. Always check the MySQL version in front of you before assuming these operators are available.


See also: Joins over Recursive Relationships, Window Functions & User-Defined Functions