S040 · CTEs & Recursive Queries
Compose complex SQL like Python functions. Learn WITH … AS as the SQL equivalent of extracting a helper, the analytics-engineering CTE-chain pattern that powers every dbt project, WITH RECURSIVE for traversing trees and graphs (org charts, folder structures, dependency graphs), the four-part recursive anatomy, and the depth-safety guard that stops runaway cycles from filling your disk.
🎯 Write readable multi-step SQL as chained CTEs, and use WITH RECURSIVE to traverse trees and graphs — the two features that upgrade you from ‘subquery jockey’ to ‘analytics engineer’.
Why this session exists
A 200-line SQL query with three levels of nested subqueries is unreviewable. Analytics engineering exists in large part because CTEs (Common Table Expressions) turned SQL into something you can compose like Python functions: one named step at a time, top to bottom, each independently testable. Every modern data stack — dbt, sqlmesh, LookML, Looker — is CTE-first. And when your data is a tree (org chart, folder structure, category hierarchy) or a graph (dependency, permissions, BOM), the recursive CTE is the only pure-SQL way to walk it. Miss this session and you'll spend the next decade shipping either unreadable subqueries or a Python loop that should have been one WITH RECURSIVE.
- Refactor a nested subquery into a CTE and explain why the shape is more maintainable.
- Chain multiple CTEs into a top-to-bottom analytics pipeline (the dbt-style pattern).
- Write a WITH RECURSIVE query with a base case, UNION ALL, and recursive step.
- Add a depth-safety guard to prevent runaway recursion on cyclic data.
- Explain the CTE materialisation model (fence vs inline) and when it changed in Postgres.
Prerequisites
- S038 — Aggregations — you'll wrap aggregate queries into named CTE stages.
- S039 — Window Functions — the natural filter-in-outer-query wrapper for
ROW_NUMBER() OVER (…).
(a) Intuition · 5 min
Nobody writes a real program as a single 200-line expression. You extract helpers: load_customers(), filter_active(), attach_revenue(), and the final rank_top_deciles(). Each helper has a name that explains its purpose; each is independently reviewable.
Nested subqueries are the SQL equivalent of that 200-line lambda: everything crammed into one expression, three levels deep, unreadable in review. A CTE is the SQL equivalent of extracting a named helper: WITH active_users AS (…), user_revenue AS (…), scored AS (…) SELECT * FROM scored.
WITH name AS (SELECT …) names an intermediate result set. Downstream queries reference it by name. The chain reads top-to-bottom like a paragraph, and each stage can be COMMENTED OUT and inspected in isolation.
Recursive CTEs (WITH RECURSIVE) add exactly one thing: the CTE can reference itself. That's how you traverse trees and graphs in pure SQL — base case + recursive step + UNION ALL + termination guard. Same shape as a recursive Python function, but expressible as a single query.
- WITH x AS (SELECT …) — name an intermediate result. Reference downstream by name.
- Multiple CTEs comma-separated — WITH a AS (…), b AS (…), c AS (…) SELECT * FROM c.
- CTEs may reference earlier CTEs in the same WITH — build pipelines top-down.
- WITH RECURSIVE — the CTE can reference itself. Base + UNION ALL + recursive step.
- AS MATERIALIZED / AS NOT MATERIALIZED (Postgres 12+) — force or block the optimisation fence.
- 1999SQL:1999 introduces WITH and WITH RECURSIVEStandardised, but slow to appear in engines. IBM DB2 shipped first.
- 2005SQL Server 2005 ships CTEsRecursive CTEs land in Microsoft's flagship — org-chart queries stop needing stored procedures.
- 2009PostgreSQL 8.4 adds CTEsOSS analytics catches up. But CTEs are always materialised — the ‘optimisation fence’ era begins.
- 2018MySQL 8.0 finally adds CTEsMySQL was almost the last major engine to support them. Legacy apps still ship without CTE usage because of MySQL 5.x holdovers.
- 2019PostgreSQL 12 inlines CTEs by defaultEnds the fence. The dbt-style CTE-chain finally has the plan cost of a nested subquery.
- 2020+dbt makes CTE-first mainstreamThe modern analytics stack settles on CTE-per-step as the house style. Nested subqueries become a code smell in reviews.
(b) Visual walkthrough · 15 min
Same query, two styles
Nested-subquery style — hard to review:
SELECT dept, avg_sal FROM (
SELECT dept, AVG(salary) AS avg_sal FROM employees
WHERE hired > '2020-01-01' GROUP BY dept
) t
WHERE avg_sal > 90000
ORDER BY avg_sal DESC;CTE style — same plan, night-and-day readability:
WITH recent_hires AS (
SELECT dept, AVG(salary) AS avg_sal
FROM employees
WHERE hired > '2020-01-01'
GROUP BY dept
)
SELECT dept, avg_sal
FROM recent_hires
WHERE avg_sal > 90000
ORDER BY avg_sal DESC;The analytics-engineering CTE chain
Every dbt model in production looks like this shape:
WITH
active_users AS (
SELECT user_id FROM events WHERE event_date > current_date - 30
GROUP BY user_id
),
user_revenue AS (
SELECT user_id, SUM(amount) AS rev FROM orders
WHERE order_date > current_date - 30
GROUP BY user_id
),
scored AS (
SELECT u.user_id, u.rev,
NTILE(10) OVER (ORDER BY u.rev DESC) AS revenue_decile
FROM active_users a JOIN user_revenue u USING (user_id)
)
SELECT * FROM scored WHERE revenue_decile = 1;Each stage has one clear responsibility. Comment out the final SELECT and replace with SELECT * FROM scored LIMIT 10 to inspect the intermediate — instant debuggability.
Pull raw rows with the tightest filter that still gives correct answers. Names the ‘input’ of the pipeline.
Join, aggregate, apply window functions. One transformation per CTE — resist stacking multiple operations into one CTE.
Attach lookup columns, compute decile bands, add derived flags.
The bottom SELECT is the deliverable. It reads like a summary of the pipeline: ‘pick the top decile’.
Recursive CTE — the classic org chart
Table employees(id, name, manager_id):
| id | name | manager_id |
|---|---|---|
| 1 | Satya | NULL |
| 2 | Rajesh | 1 |
| 3 | Priya | 1 |
| 4 | Alice | 2 |
| 5 | Bob | 2 |
| 6 | Carol | 3 |
| 7 | Dan | 4 |
Find everyone in Satya's reporting tree, with depth:
WITH RECURSIVE reports AS (
-- base case: the root
SELECT id, name, manager_id, 0 AS depth
FROM employees
WHERE id = 1
UNION ALL
-- recursive step: children of anyone already in `reports`
SELECT e.id, e.name, e.manager_id, r.depth + 1
FROM employees e
JOIN reports r ON e.manager_id = r.id
WHERE r.depth < 20 -- safety guard
)
SELECT * FROM reports ORDER BY depth, id;Iteration walk:
Recursive-CTE anatomy — the four required parts
Every recursive CTE has exactly these pieces
Recursive without a source table — number and date generators
-- Numbers 1..100 with their squares (SQLite / Postgres portable)
WITH RECURSIVE nums(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM nums WHERE n < 100
)
SELECT n, n * n AS square FROM nums;
-- All days in January 2026 (works everywhere without generate_series)
WITH RECURSIVE cal(d) AS (
SELECT DATE('2026-01-01')
UNION ALL
SELECT DATE(d, '+1 day') FROM cal WHERE d < DATE('2026-01-31')
)
SELECT d FROM cal;Perfect for backfilling missing dates in time-series reports — join the calendar CTE against your sparse fact table with a LEFT JOIN so zero-activity days show up as rows instead of gaps.
CTEs vs subqueries vs temp tables — pick your tool
Named intermediate, single query scope
- Best readability; the modern default
- Postgres 12+: same plan as inline subquery
- Recursive form only via CTE — no other way
- dbt / sqlmesh models are 100 % CTE-first
Anonymous inline result
- Fine for one-off ad-hoc analysis
- Becomes unreviewable past two levels of nesting
- Cannot self-reference (no recursion)
- Silently duplicates work across sibling subqueries
Persisted intermediate across queries
- Use when you need the intermediate across MULTIPLE queries in a session
- Materialised once, indexable, ANALYZE-able
- Session-scoped by default (auto-drops on disconnect)
- Overkill for a single query — reach for a MATERIALIZED CTE instead
"A CTE is a temporary table — the engine computes it once and reuses it, so referencing it twice is free."
Whether a CTE is materialised is engine-dependent and often plan-dependent. Many optimisers inline the CTE into each reference, re-executing it every time. Others materialise it and thereby block predicate pushdown, making the query slower than the equivalent subquery. A CTE is a naming construct, not a caching directive.
Because the syntax reads like an assignment — WITH x AS (...) looks exactly like defining a variable — and in most languages defining a value computes it once. Historically Postgres did always materialise CTEs (an optimisation fence), which taught a generation one rule, and then Postgres 12 changed the default to inlining, which taught the opposite. Both behaviours are real; neither is guaranteed.
Check the plan rather than assuming — the same CTE can appear once or twice:
-- Postgres 12+ lets you state the intent explicitly:
WITH heavy AS MATERIALIZED (
SELECT customer_id, SUM(amount) AS total
FROM orders GROUP BY customer_id
)
SELECT * FROM heavy a JOIN heavy b USING (customer_id);
-- vs NOT MATERIALIZED, which inlines and re-executes
WITH heavy AS NOT MATERIALIZED ( ... ) SELECT ...;
-- The only reliable way to know what your engine did:
EXPLAIN ANALYZE <your query>;
-- count how many times the CTE's scan appears in the planWhy does a recursive CTE need exactly two parts joined by UNION ALL, and why does it terminate at all? A query that references itself sounds like an infinite loop.
- 1A recursive CTE is defined as a fixed-point computation: repeatedly apply a rule until applying it produces nothing new.forced by · that is the only way to express transitive closure — "reachable" is defined in terms of itself
- 2A fixed-point iteration needs a starting set, which is the anchor member — the non-recursive branch.forced by · you cannot iterate from nothing; the recursion must be seeded with base facts
- 3Each iteration applies the recursive member to only the rows produced by the previous iteration, not to the whole accumulated result.forced by · re-deriving already-known rows would be pure waste; semi-naive evaluation only extends the frontier
- 4Termination occurs when an iteration produces zero new rows, because the next iteration would then have no input and could produce nothing.forced by · the recursive step is monotone — it only adds — so an empty frontier is a genuine fixed point
- 5
UNION ALLis required rather thanUNIONbecause deduplicating each iteration would change the semantics and cost; termination is meant to come from the recursive member's own join condition running out of matches.forced by · the frontier shrinking to empty is a property of the data traversal, not of duplicate elimination
Therefore a recursive CTE is breadth-first search expressed declaratively: the anchor is the starting frontier, the recursive member is the edge-expansion step, and termination is an empty frontier.
And note what this predicts: on a graph with a cycle, the frontier never empties and the query runs forever (or until a depth limit). The fix is exactly the fix in BFS — carry a visited set, typically as an array of the path so far, and exclude nodes already in it. If you have ever written a recursive CTE over an org chart or a dependency graph and had it hang, that is the missing visited set, and the graph derivation tells you so directly.
A CTE is a label on an intermediate relation. You are not creating storage; you are naming a stage so that the next stage can refer to it by name instead of by a nested block of parentheses. The query becomes a readable sequence of transformations rather than a pyramid you have to read inside out.
The value is almost entirely in comprehension and testability: each CTE can be selected from on its own to check its row count and grain, which is how you localise a wrong number to one stage instead of bisecting a 200-line query.
- Name CTEs after the grain and meaning of their output —
orders_per_customer, notstep2. The name is documentation of the contract. - One CTE, one transformation. A CTE that filters and joins and aggregates is just a subquery with a label on it.
- A CTE is not an optimisation. If you need reuse guarantees, use a temp table or an explicit
MATERIALIZEDhint where the engine supports it. - Recursive CTEs need an anchor, a recursive member,
UNION ALL, and — on any graph that might cycle — a visited-path guard and a depth cap.
Fire this model the moment you see: a nested subquery more than two levels deep · the same subquery text repeated · a hierarchy (org chart, category tree, bill of materials, task dependency DAG) · "walk up the parents until you reach the root" · a query nobody on the team is willing to modify.
An intermediate result is used several times in a large query. CTE, temporary table, or materialised view?
Default to CTEs for structure, because query readability has a real and recurring cost that is easy to underestimate — a 200-line nested query is a query nobody dares change, and that is a business risk, not a style complaint. Reach for a temp table when the plan proves you need it, not on suspicion.
The diagnostic that settles it: run EXPLAIN ANALYZE and count how many times the CTE's scan appears in the plan, and compare estimated versus actual rows. If it appears three times and it is expensive, materialise it. If the estimate is wildly off, materialising also gets you statistics, which is often the larger win.
(c) Hands-on · 25 min
Save as cte_lab.py, run python3 cte_lab.py. SQLite 3.8+ supports recursive CTEs (default in all modern Python).
"""cte_lab.py — CTE chains and recursive traversals."""
from __future__ import annotations
import sqlite3
conn = sqlite3.connect(":memory:")
cur = conn.cursor()
cur.executescript("""
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
manager_id INTEGER,
salary INTEGER,
hired TEXT
);
INSERT INTO employees VALUES
(1, 'Satya', NULL, 5000000, '2014-02-04'),
(2, 'Rajesh', 1, 2200000, '2015-05-10'),
(3, 'Priya', 1, 2100000, '2016-08-15'),
(4, 'Alice', 2, 600000, '2020-03-01'),
(5, 'Bob', 2, 450000, '2021-06-15'),
(6, 'Carol', 3, 550000, '2019-01-10'),
(7, 'Dan', 4, 180000, '2023-11-05');
CREATE TABLE categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER
);
INSERT INTO categories VALUES
(1, 'Electronics', NULL),
(2, 'Phones', 1),
(3, 'Smartphones', 2),
(4, 'Feature Phones', 2),
(5, 'Laptops', 1),
(6, 'Gaming Laptops', 5),
(7, 'Books', NULL),
(8, 'Fiction', 7);
""")
def run(label: str, sql: str) -> None:
print(f"\n-- {label} --")
for row in cur.execute(sql):
print(" ", row)
# ---- 1. Basic CTE — nested subquery rewritten ----
run("1. CTE — recent hires by manager, above 400k avg", """
WITH recent AS (
SELECT manager_id, AVG(salary) AS avg_sal
FROM employees
WHERE hired > '2019-01-01'
GROUP BY manager_id
)
SELECT r.*, e.name AS manager_name
FROM recent r LEFT JOIN employees e ON e.id = r.manager_id
WHERE r.avg_sal > 400000;
""")
# ---- 2. Multi-CTE chain — the analytics-engineering pattern ----
run("2. Multi-CTE — top-band earners with manager attached", """
WITH banded AS (
SELECT id, name, salary, manager_id,
NTILE(3) OVER (ORDER BY salary DESC) AS band
FROM employees
),
top_band AS (SELECT * FROM banded WHERE band = 1),
enriched AS (
SELECT t.name, t.salary, m.name AS manager
FROM top_band t LEFT JOIN employees m ON m.id = t.manager_id
)
SELECT * FROM enriched ORDER BY salary DESC;
""")
# ---- 3. Recursive — full org tree with depth and path ----
run("3. Recursive — everyone under Satya, with depth and path", """
WITH RECURSIVE reports AS (
SELECT id, name, manager_id, 0 AS depth,
CAST(name AS TEXT) AS path
FROM employees WHERE id = 1
UNION ALL
SELECT e.id, e.name, e.manager_id, r.depth + 1,
r.path || ' > ' || e.name
FROM employees e
JOIN reports r ON e.manager_id = r.id
WHERE r.depth < 20
)
SELECT depth, name, path FROM reports ORDER BY depth, id;
""")
# ---- 4. Recursive upward — Dan's management chain to the CEO ----
run("4. Recursive — Dan's management chain up to the top", """
WITH RECURSIVE chain AS (
SELECT id, name, manager_id, 0 AS level
FROM employees WHERE name = 'Dan'
UNION ALL
SELECT e.id, e.name, e.manager_id, c.level + 1
FROM employees e JOIN chain c ON e.id = c.manager_id
WHERE c.level < 20
)
SELECT level, name FROM chain ORDER BY level;
""")
# ---- 5. Recursive number generator — no source table ----
run("5. Recursive number gen — 1..10 with squares", """
WITH RECURSIVE nums(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM nums WHERE n < 10
)
SELECT n, n * n AS square FROM nums;
""")
# ---- 6. Recursive date series — every day in Jan 2026 ----
run("6. Recursive date series — all days in Jan 2026 (first 5)", """
WITH RECURSIVE cal(d) AS (
SELECT DATE('2026-01-01')
UNION ALL
SELECT DATE(d, '+1 day') FROM cal WHERE d < DATE('2026-01-31')
)
SELECT d FROM cal LIMIT 5;
""")
# ---- 7. Recursive summarisation — headcount rollup ----
run("7. Total direct + indirect reports under each manager", """
WITH RECURSIVE tree AS (
SELECT id AS root, id, name
FROM employees
UNION ALL
SELECT t.root, e.id, e.name
FROM tree t JOIN employees e ON e.manager_id = t.id
)
SELECT r.name AS manager, COUNT(*) - 1 AS reports_count
FROM tree t JOIN employees r ON r.id = t.root
GROUP BY t.root, r.name
HAVING reports_count > 0
ORDER BY reports_count DESC;
""")
# ---- 8. Category hierarchy — full path per category ----
run("8. Recursive — category paths (Electronics / Phones / Smartphones)", """
WITH RECURSIVE cat_path AS (
SELECT id, name, parent_id, name AS path, 0 AS depth
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id,
cp.path || ' / ' || c.name, cp.depth + 1
FROM categories c
JOIN cat_path cp ON c.parent_id = cp.id
WHERE cp.depth < 20
)
SELECT depth, path FROM cat_path ORDER BY path;
""")
# ---- 9. Cycle-detection demo — what happens without a guard ----
# We intentionally do NOT introduce a cycle here (it would loop forever).
# Instead, show the guard IS the fix.
run("9. Guarded recursion — safe even with pathological data", """
WITH RECURSIVE safe_walk AS (
SELECT id, name, manager_id, 0 AS depth FROM employees WHERE id = 1
UNION ALL
SELECT e.id, e.name, e.manager_id, s.depth + 1
FROM employees e JOIN safe_walk s ON e.manager_id = s.id
WHERE s.depth < 5 -- HARD STOP at depth 5
)
SELECT COUNT(*) AS rows_returned FROM safe_walk;
""")
conn.close()Anatomy of the script
Add a products table:
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT,
category_id INTEGER REFERENCES categories(id)
);
INSERT INTO products VALUES
(1, 'iPhone 15', 3), -- Smartphones
(2, 'Pixel 8', 3),
(3, 'Nokia 3310', 4), -- Feature Phones
(4, 'ThinkPad X1', 5), -- Laptops
(5, 'ROG Zephyrus', 6), -- Gaming Laptops
(6, 'Dune', 8); -- FictionNow compute product counts rolled up to each category INCLUDING descendants (so Electronics should show 5 — all phones + laptops):
WITH RECURSIVE descendants AS (
SELECT id AS root, id AS descendant FROM categories
UNION ALL
SELECT d.root, c.id
FROM descendants d JOIN categories c ON c.parent_id = d.descendant
)
SELECT c.name AS category, COUNT(p.id) AS product_count
FROM categories c
LEFT JOIN descendants d ON d.root = c.id
LEFT JOIN products p ON p.category_id = d.descendant
GROUP BY c.id, c.name
ORDER BY product_count DESC;Expected: Electronics=5, Phones=3, Smartphones=2, Feature Phones=1, Laptops=2, Gaming Laptops=1, Books=1, Fiction=1.
(d) Production reality · 15 min
Standard story from any team that upgraded style before upgrading engine version: someone refactored a set of nested subqueries into CTEs for a dashboard query. Same result, cleaner code. But on Postgres 9 / 10 / 11, every CTE was a materialisation boundary — WITH tiny AS (SELECT * FROM huge WHERE id = 42) materialised the entire filtered result before use, even for a trivial one-row lookup. Queries that used to take 50 ms started taking 30 seconds. The dashboard timed out for a week before someone read `EXPLAIN`.
Two fixes: (1) upgrade to Postgres 12+, which inlines non-recursive CTEs by default. (2) On older engines (or Redshift, which is forever Postgres 8.0), rewrite hot-path CTEs back to subqueries, or add `WITH x AS NOT MATERIALIZED (…)` on Postgres 12+ if you want to be explicit. Always compare `EXPLAIN` before and after any CTE refactor on a table > 1 M rows.
A common war story shape: a company's org data had a data-entry bug — two employees listed each other as manager (a cycle: A reports to B reports to A). The nightly recursive report `WITH RECURSIVE reports AS (…) SELECT * FROM reports` had no depth guard. The recursive step kept finding ‘new’ children forever, spitting out hundreds of millions of duplicated rows. It ran for hours, filled the tempdb disk, and paged the on-call at 3 AM.
Two-line fix: WHERE r.depth < 20 in the recursive step (cheap hard cap on iterations), plus optional cycle detection with a visited-array pattern on Postgres (array_append(visited, e.id), WHERE NOT e.id = ANY(visited)). Both were missing; both should be default. Also: never let a recursive report run without a runaway budget.
Before dbt (Fishtown Analytics, later dbt Labs) popularised the modular SQL model in 2018–2020, data teams shipped 500-line hand-written SQL files with three levels of nested subqueries per query. Code review was impossible. Bugs hid in the middle nested block. New engineers took weeks to onboard onto a single dashboard's SQL.
dbt's convention: every model file is a top-to-bottom chain of small named CTEs (staging → intermediate → mart). Each CTE does one thing. The final SELECT is the deliverable. This is now the house style at every serious analytics org (Airbnb's Minerva, Netflix's data platform, Uber's Presto queries). Reading a well-structured dbt project is a genuine pleasure; you can trace any column's lineage from raw source to final report by following CTE names.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- What is a CTE, in one sentence? (a named intermediate result you can reference below)
- Why is chained CTEs easier than nested subqueries? (names, one-thing-per-step, independent debugging)
- When do you reach for WITH RECURSIVE? (trees, graphs, series generation)
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.