Joins, CTEs & Recursion
GROUP BY, HAVING, CTEs, recursive queries
SQL Basics covered SELECT against a single
table. Almost nothing interesting stays in one table for long —
joins combine rows across tables, GROUP BY/HAVING collapse them
into summaries, and CTEs (WITH ...) let you name intermediate
results instead of nesting subqueries five levels deep.
Join types
Sample schema for this lesson: employees(id, name, manager_id, department_id) and departments(id, name).
-- only rows that match on both sides
SELECT e.name, d.name AS department
FROM employees e
INNER JOIN departments d ON e.department_id = d.id;
-- every employee, department NULL if unassigned
SELECT e.name, d.name AS department
FROM employees e
LEFT JOIN departments d ON e.department_id = d.id;
-- every department, employee NULL if empty
SELECT e.name, d.name AS department
FROM employees e
RIGHT JOIN departments d ON e.department_id = d.id;
-- everything from both sides, NULLs where either side is missing
SELECT e.name, d.name AS department
FROM employees e
FULL JOIN departments d ON e.department_id = d.id;INNER JOIN is the default JOIN — rows drop out entirely if either
side has no match. LEFT JOIN (and its mirror, RIGHT JOIN) keeps
every row from the "kept" side and fills unmatched columns with
NULL. FULL JOIN keeps everything from both sides. In practice
RIGHT JOIN is rare — it's almost always written as a LEFT JOIN
with the tables swapped instead, since that reads more naturally
left-to-right.
GROUP BY and HAVING
GROUP BY collapses matching rows into one row per group; HAVING
filters groups the same way WHERE filters rows, but it runs
after aggregation, so it can reference aggregate functions where
WHERE can't:
SELECT department_id, count(*) AS headcount
FROM employees
GROUP BY department_id
HAVING count(*) > 3
ORDER BY headcount DESC;WHERE filters rows before grouping happens; HAVING filters the
groups produced by that grouping. WHERE count(*) > 3 is a syntax
error for exactly this reason — at the point WHERE runs, count(*)
doesn't exist yet.
CTEs: naming intermediate results
A CTE (WITH ... AS (...)) gives a subquery a name you can reference
later in the statement, one or more times:
WITH dept_counts AS (
SELECT department_id, count(*) AS headcount
FROM employees
GROUP BY department_id
)
SELECT d.name, dc.headcount
FROM dept_counts dc
JOIN departments d ON d.id = dc.department_id
ORDER BY dc.headcount DESC;This is purely a readability tool for non-recursive CTEs — every one
of them can be rewritten as a nested subquery. The value is that a
chain of WITH clauses reads top-to-bottom like a sequence of steps,
instead of nesting inside-out.
Before Postgres 12, every CTE was an optimization fence: the
planner executed it in isolation and materialized the full result
before the outer query ran, so a filter in the outer query couldn't
push down into it. Postgres 12+ inlines non-recursive CTEs by
default, treating WITH more like a named subquery the planner is
free to rewrite — filters and joins can push down into it just like
a regular subquery. If you specifically need the old fencing
behavior (say, to guarantee a side-effecting or expensive
computation runs exactly once), force it explicitly with WITH x AS MATERIALIZED (...). Recursive CTEs can't be inlined and remain
fenced either way.
Recursive CTEs: walking a tree
WITH RECURSIVE is the standard way to walk hierarchical data — org
charts, category trees, bill-of-materials — in plain SQL, without a
procedural loop. It has two parts joined by UNION ALL: an anchor
that seeds the starting rows, and a recursive term that references
the CTE's own name and is re-executed against only the rows produced
by the previous iteration, until it returns nothing.
WITH RECURSIVE org_chart AS (
-- anchor: top of the tree
SELECT id, name, manager_id, 1 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- recursive term: one level down each pass
SELECT e.id, e.name, e.manager_id, oc.depth + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT repeat(' ', depth - 1) || name AS org_tree, depth
FROM org_chart
ORDER BY depth, name;Each pass only sees the rows the previous pass added — not the
whole accumulated result — which is what makes this a proper
breadth-first traversal and not an infinite self-join. UNION ALL
(not UNION) is deliberate: deduplicating would require comparing
every new row against everything accumulated so far, which is both
slower and wrong if you genuinely expect duplicate rows (e.g. the
same employee ID reachable via two manager paths in a non-tree
graph).
A recursive CTE over a graph with cycles (not a strict tree) can
loop forever unless you break it yourself — track visited IDs in an
array column and add WHERE NOT e.id = ANY(oc.path) to the
recursive term, or cap it with a depth < n guard.
Next, a different way to keep every row instead of collapsing them into groups: Window Functions.