Module 8 · Advanced SQL
Advanced joins
Join a table to itself, test for existence with semi- and anti-joins, walk a hierarchy with a recursive CTE, join on ranges, and build complete grids with CROSS JOIN.
About 20 minutes
The problem
HR asks for a staff list showing each person's manager. The employees table has a manager_id column, but it holds a number, not a name, and the manager is just another row in the same table.
The same week, sales asks, "Which customers have ever used air freight?" A plain join returns one row per air shipment, so a customer with 30 air shipments appears 30 times. You could add DISTINCT, but that hides the real question, which is about existence, not about combining rows.
Joins you already know (INNER, LEFT) combine matching rows. This lesson covers the joins that answer other kinds of questions.
The concept
| Join | Answers | Pattern |
|---|---|---|
| Self join | How does a row relate to another row in the same table? | employees AS e LEFT JOIN employees AS m ON m.employee_id = e.manager_id |
| Semi-join | Which rows have at least one match? | WHERE EXISTS (SELECT 1 FROM … WHERE …) |
| Anti-join | Which rows have no match? | WHERE NOT EXISTS (…) |
| Recursive CTE | Who's under whom, at any depth? | WITH RECURSIVE |
| Range (non-equi) join | Which band or period does a value fall in? | ON value BETWEEN band.lo AND band.hi |
| Cross join | Every combination, even ones with no data | routes CROSS JOIN months |
A semi-join never duplicates rows, however many matches there are, and it stops looking at the first match.
Recursive CTEs have two parts joined by UNION ALL: an anchor (the starting rows, such as people with no manager) and a recursive part that joins the CTE to the table to find the next level down. It repeats until a level finds no new rows. Always carry a level column, and make sure the recursion can end: a loop in the data (A manages B, B manages A) would run forever, and a WHERE level < 10 guard prevents that.
Example
The staff list with managers, via a self join. The same table appears twice, with different aliases:
SELECT
e.employee_id,
e.full_name,
e.role,
COALESCE(m.full_name, '(no manager)') AS manager
FROM employees AS e
LEFT JOIN employees AS m ON m.employee_id = e.manager_id
ORDER BY e.employee_id;LEFT JOIN keeps the two team leads, who have no manager. An inner join would silently drop the most senior people in the list.
Now the whole reporting tree, with a recursive CTE:
WITH RECURSIVE org AS (
SELECT employee_id, full_name, role, 0 AS level, full_name AS path
FROM employees
WHERE manager_id IS NULL -- anchor: the top of the tree
UNION ALL
SELECT e.employee_id, e.full_name, e.role, o.level + 1, o.path || ' > ' || e.full_name
FROM employees AS e
JOIN org AS o ON e.manager_id = o.employee_id -- the next level down
WHERE o.level < 10 -- guard against loops
)
SELECT level, path, role
FROM org
ORDER BY path;Harbourline's tree is only two levels deep, but the same query works unchanged for a company with ten.
Walkthrough
- Run the self join. Change
LEFT JOINtoJOINand count the rows: 22, not 24. - Run the recursive CTE, then remove
WHERE manager_id IS NULLfrom the anchor. Every employee becomes a starting point and people appear several times. The anchor decides where the tree starts. - Try a range join. Group shipments into size bands defined in a small inline table:
WITH bands(band, lo, hi) AS (
VALUES ('Small (1-2)', 1, 2), ('Medium (3-5)', 3, 5), ('Large (6-8)', 6, 8)
)
SELECT b.band, COUNT(*) AS shipments, ROUND(AVG(s.freight_charge)) AS avg_charge
FROM shipments AS s
JOIN bands AS b ON s.containers BETWEEN b.lo AND b.hi
GROUP BY b.band, b.lo
ORDER BY b.lo;- Note that the bands live in data, not in a long
CASE. When finance changes the bands, you change three rows, not the query.
Practice
Practice
Which customers have ever booked an Air shipment? Use EXISTS. Show customer_id and company_name, ordered by customer_id.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Practice
For each manager, count their direct reports. Show manager_id, manager_name and direct_reports, most reports first (ties by manager_id).
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Challenge
Challenge · optional
Build a complete grid of every route and every month of 2026 (January to August), with the number of shipments booked, 0 where none. Show route_id, month ('YYYY-MM') and shipments, ordered by route_id then month.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
More practice
Drill · optional
Find routes that have never had a cancelled shipment. Show route_id, origin and destination, ordered by route_id. Use NOT EXISTS.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Drill · optional
Find pairs of employees in the same team who were hired in the same year. Show team, hire_year, employee_a and employee_b (full names), each pair once, ordered by team, hire_year, employee_a.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Check your understanding
Answer every question to check.