Search Tech Journey

Find topics, journeys and posts

6-month learning plan40 / 130
back to blog
data engineeringbeginner 55m read

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.

🗃️DatabasesM04 · Databases & SQL· Session 040 of 130 90 min

🎯 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.

You will be able to
  • 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



(a) Intuition · 5 min

A Python script with helper functions vs one 200-line lambda
🌍 Real world

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.

💻 Code world

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.

The CTE starter pack
  • 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.
  1. 1999
    SQL:1999 introduces WITH and WITH RECURSIVE
    Standardised, but slow to appear in engines. IBM DB2 shipped first.
  2. 2005
    SQL Server 2005 ships CTEs
    Recursive CTEs land in Microsoft's flagship — org-chart queries stop needing stored procedures.
  3. 2009
    PostgreSQL 8.4 adds CTEs
    OSS analytics catches up. But CTEs are always materialised — the ‘optimisation fence’ era begins.
  4. 2018
    MySQL 8.0 finally adds CTEs
    MySQL was almost the last major engine to support them. Legacy apps still ship without CTE usage because of MySQL 5.x holdovers.
  5. 2019
    PostgreSQL 12 inlines CTEs by default
    Ends the fence. The dbt-style CTE-chain finally has the plan cost of a nested subquery.
  6. 2020+
    dbt makes CTE-first mainstream
    The 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.

1extract
Stage 1 — extract

Pull raw rows with the tightest filter that still gives correct answers. Names the ‘input’ of the pipeline.

2transform
Stage 2 — transform

Join, aggregate, apply window functions. One transformation per CTE — resist stacking multiple operations into one CTE.

3enrich
Stage 3 — enrich

Attach lookup columns, compute decile bands, add derived flags.

4output
Stage 4 — final SELECT

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):

idnamemanager_id
1SatyaNULL
2Rajesh1
3Priya1
4Alice2
5Bob2
6Carol3
7Dan4

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

1. WITH RECURSIVE name AS (…)
The `RECURSIVE` keyword unlocks self-reference. Without it the CTE cannot refer to itself.
declare
2. Base query (SELECT …)
The starting rows. Must produce a finite result — this is where the recursion begins.
seed
3. UNION ALL
Combines the base rows with each recursive iteration. UNION (with dedup) also works but is much slower — see the gotcha below.
combine
4. Recursive query (SELECT … FROM tbl JOIN name)
References the CTE itself. Executes repeatedly with the previous iteration's output as input. Terminates when the recursive step returns zero rows.
expand
5. Safety guard (WHERE depth < N)
Not syntactically required — but not optional in production. Cycles or bad data will otherwise fill your disk and page the on-call.
safety

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

CTE (WITH …)

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
Nested subquery

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
Temp table

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

Common misconception
✗ What most people think

"A CTE is a temporary table — the engine computes it once and reuses it, so referencing it twice is free."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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 plan
From first principles
Start with the question

Why 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.

  1. 1
    A 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
  2. 2
    A 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
  3. 3
    Each 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
  4. 4
    Termination 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. 5
    UNION ALL is required rather than UNION because 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

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.

Mental modelNamed steps in a pipeline

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, not step2. 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 MATERIALIZED hint 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.
🔔 Fires when you see

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.

The tradeoff

An intermediate result is used several times in a large query. CTE, temporary table, or materialised view?

CTE
+ you gain zero setup, no cleanup, no permissions needed, and the whole query stays a single atomic statement the optimiser can reason about globally
− you pay no reuse guarantee — it may be recomputed per reference — and no statistics, so the optimiser is estimating blind for anything downstream of it
pick when the CTE is cheap or referenced once, and readability is the goal — which covers the large majority of uses
Temporary table
+ you gain computed exactly once, and you can index it and let the engine gather statistics, which often produces a dramatically better plan for the downstream joins
− you pay breaks the query into multiple statements, so it needs a session or a transaction, cleanup, and write permissions; you also lose whole-query optimisation across the boundary
pick when the intermediate is expensive and referenced multiple times, or the optimiser is visibly misestimating its cardinality
Materialised view / persisted table
+ you gain cost is paid once and amortised across every query and every user; can be incrementally refreshed, partitioned and clustered like any table
− you pay you now own freshness and a refresh schedule, plus storage, plus the question of what consumers see mid-refresh
pick when the same intermediate is needed by multiple queries or dashboards on an ongoing basis and a defined staleness is acceptable
What a senior engineer actually does

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

Query 1 — the CTE-vs-subquery win
Compare against the equivalent nested subquery: same plan on Postgres 12+, but a code reviewer can immediately see the two logical steps.
readability
Query 2 — three-stage CTE chain
The dbt-style pipeline shape. `banded` computes the input tier; `top_band` filters; `enriched` decorates. Each stage does exactly one thing.
pipeline
Query 3 — org tree with breadcrumb path
The path column (`Satya > Rajesh > Alice > Dan`) shows how string accumulation across recursion iterations produces a human-readable ancestry trail — used by every RBAC and file-tree implementation.
tree
Query 4 — walk UP instead of down
Same recursive skeleton, join direction reversed. Base = the leaf; recursive step joins ancestors. Useful for ‘who is my manager's manager?’ questions.
upward
Query 5 — number gen with no source table
The escape hatch when you need a series and your engine lacks generate_series (SQLite, older MySQL). Base = the first number; recursive step increments.
series
Query 6 — date backfill
The template for ‘render 0 for every day with no orders’ dashboards. Join the calendar CTE LEFT against the sparse fact table.
backfill
Query 7 — rooted headcount rollup
For every root, walk down; count all descendants. Same shape as ‘total files in this folder tree’ or ‘total revenue under this org node’.
rollup
Query 8 — category path building
The e-commerce / marketplace pattern. Attach a full slash-separated path to every category node in one pass — enables breadcrumbs everywhere without an application-side loop.
hierarchy
Query 9 — the safety guard
`WHERE depth < 5` is the difference between ‘runs in 3 ms’ and ‘runs until disk is full’. Type it every time, even when you're sure your data is acyclic.
safety
Try itCategory rollup — count products per category including descendants

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);   -- Fiction

Now 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.

💡 Hint · Two CTEs: first the recursive descendant map, then a JOIN + GROUP BY on the descendants.

(d) Production reality · 15 min

War story The Postgres 11 CTE fence — every team learned this the hard way30× slowdown on production dashboards after ‘readability refactor’
🔥 What broke

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`.

🧯 The fix

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.

🎓 Lesson to steal
Readability wins can be silent perf disasters if you don't know your engine's optimiser behaviour. Postgres 12 changed the rule. Redshift never got the change. Always `EXPLAIN` after refactor.
Post-mortem
War story Any org data with data-entry errors — the recursive cycle disasterNightly report runs for hours, fills disk, pages on-call
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
Every WITH RECURSIVE in production needs a depth guard. Type it every time, even when you're sure the data is a tree. Data is never as clean as you think.
War story The dbt / analytics-engineering revolutionThousands of data teams standardised on CTE-first SQL after 2018
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
The technology (CTEs) existed for 15 years. The house style (chain of small named CTEs) took another decade to become industry norm. Adopt the shape now; every senior data engineer job posting will assume it.
Post-mortem

Where this shows up in the rest of the plan

CTEs are the connective tissue of readable analytics SQL
S039 · Window functions
The natural wrapper: `WITH ranked AS (SELECT …, ROW_NUMBER() OVER …) SELECT * FROM ranked WHERE rn = 1`.
S041 · Indexes
MATERIALIZED CTEs create implicit temp tables. Adding an index on the CTE requires making it a real temp table instead.
S049 · Query optimisation
Learn to read EXPLAIN for CTE nodes: `CTE Scan` vs inlined subquery scan.
S054 · dbt & data modelling
The entire modern ELT toolchain assumes CTE-first SQL as the input format.
S066 · Graph queries
Recursive CTEs are the poor person's graph query. For real graphs (Cypher, Gremlin) you outgrow SQL — but recursive CTE covers 80 % of cases.
S086 · Data lineage
dbt's lineage graph is literally derived from parsing WITH clauses in SQL model files.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

  1. What is a CTE, in one sentence? (a named intermediate result you can reference below)
  2. Why is chained CTEs easier than nested subqueries? (names, one-thing-per-step, independent debugging)
  3. 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.