advanced~4h

CTEs — Common Table Expressions & Recursive CTEs

A Common Table Expression (CTE) is a named temporary result set defined within a WITH clause, available for the duration of the query. CTEs improve readability, enable recursion, and can replace compl

Complex queries built as deeply nested subqueries become unreadable fast — CTEs exist to let you name and structure intermediate query steps clearly, the same way naming a variable clarifies a piece of application code instead of inlining it everywhere.

Hierarchical data (an org chart, a category tree, a comment thread) has a variable, unknown depth — a fixed number of JOINs can't traverse "however many levels deep this happens to go." Recursive CTEs are SQL's built-in mechanism for traversing exactly that kind of structure, however deep it turns out to be.

CTE (Common Table Expression) — a named, temporary result set defined with WITH, usable within the rest of that single query, as if it were a table. Recursive CTE — a CTE that references itself, with a base case (the starting rows) and a recursive case (rows built from the previous iteration), continuing until the recursive case returns no more rows.

A Common Table Expression (CTE) is a named temporary result set defined within a WITH clause, available for the duration of the query. CTEs improve readability, enable recursion, and can replace complex subqueries.

CTE syntax

WITH cte_name AS (

SELECT ...

)

SELECT * FROM cte_name;

Multiple CTEs

WITH cte1 AS (...), cte2 AS (...), cte3 AS (...)

SELECT ...

CTE types

  • Regular CTE — improves readability; often (not always) inlined by optimizer.

  • Recursive CTE — enables hierarchical/graph traversal without application-level loops.

  • Materialized CTE — WITH ... AS MATERIALIZED forces evaluation once (PostgreSQL 12+).

  • Non-Materialized CTE — WITH ... AS NOT MATERIALIZED inlines the CTE (optimizer choice).

  • Writeable CTE — CTE can contain INSERT, UPDATE, DELETE (PostgreSQL). Very powerful.

Recursive CTE structure

WITH RECURSIVE cte AS (

-- Anchor member (starting point, non-recursive)

SELECT ... FROM base_table WHERE condition

UNION ALL

-- Recursive member (references the CTE itself)

SELECT ... FROM other_table

JOIN cte ON joining_condition

)

SELECT * FROM cte;

Regular CTE execution

  • PostgreSQL optimizer decides to inline or materialize the CTE.

  • If inlined: CTE SQL is substituted inline — optimizer sees through it and optimizes.

  • If materialized: CTE evaluated once, result stored in a temporary working table.

  • Main query uses the CTE result.

Recursive CTE execution

  • Anchor member evaluated — produces initial rows (working table T0).

  • Recursive member evaluates with T0 as input — produces T1.

  • Recursive member evaluates with T1 as input — produces T2.

  • Continues until recursive member produces 0 rows (no new rows to add).

  • All rows from T0, T1, T2... are UNION ALLed together.

  • Main query receives the accumulated result.

  • Safety: CYCLE detection (PostgreSQL 14+) or manual depth limit prevents infinite loops.

Step 1: Write the anchor SELECT — identifies starting rows (WHERE manager_id IS NULL for root).

Step 2: Write UNION ALL and the recursive SELECT.

Step 3: In recursive SELECT: JOIN the CTE to get children of current level.

Step 4: Add a depth/level column to track hierarchy level (depth + 1).

Step 5: Build path string for visualization: path || emp_name.

Step 6: Add cycle detection with CYCLE clause (PostgreSQL 14+) or manual array check.

Step 7: Add LIMIT on recursion depth if needed: WHERE depth < 10.

  • Use CTEs to break complex queries into readable named steps — each CTE has a clear purpose.

  • MATERIALIZED CTEs when: the subquery is expensive and used multiple times; optimizer might re-evaluate otherwise.

  • NOT MATERIALIZED (default PostgreSQL 12+): let optimizer inline for better plan with indexes.

  • Always add CYCLE detection for recursive CTEs — prevent infinite loops on data with circular references.

  • Add depth limit WHERE depth < 100 — safety net for deep or cyclic hierarchies.

  • Recursive CTE for hierarchies — avoid multiple self-joins or application-level loops.

  • Writeable CTEs for atomic multi-step DML — update + audit in one statement.

  • Test recursive CTEs with small data first — verify anchor and recursive members independently.

  • Use generate_series() (PostgreSQL) for date/number sequences instead of recursive CTE.

  • Named CTEs improve maintainability — changes in one CTE automatically propagate to referencing queries.

  • Infinite recursion — forgetting the WHERE/JOIN condition that terminates recursion. Always test with LIMIT first.

  • UNION instead of UNION ALL in recursive CTE — UNION deduplicates on each iteration (very slow); almost always use UNION ALL.

  • Assuming CTE is always materialized (PostgreSQL 12+) — default is inline. Use AS MATERIALIZED if you need one evaluation.

  • Not handling cycles in data — self-referential relationships can have cycles (employee is their own manager due to data error).

  • Referencing a CTE multiple times thinking it runs once — without MATERIALIZED, may run multiple times.

  • Complex recursive logic — debug by adding WHERE depth < 3 and checking intermediate results.

  • Forgetting all recursive CTEs need RECURSIVE keyword — WITH RECURSIVE (not just WITH).

  • Non-materialized CTE (default PostgreSQL 12+): optimizer can push WHERE predicates into the CTE — better index use.

  • Materialized CTE: prevents multiple evaluations of expensive subqueries; trades recomputation for memory.

  • Recursive CTE performance: dependent on depth and rows per level; index the JOIN column (manager_id).

  • Alternative to deep recursion: store the full path in the table (materialized path pattern) for very deep hierarchies.

  • For static hierarchies: closure table pattern — pre-compute all ancestor-descendant pairs; O(1) lookups.

  • ltree extension (PostgreSQL): optimized for hierarchical data with GiST index support.

A recursive CTE with no depth limit and a cyclic underlying data structure (e.g., a corrupted org-chart with a reporting loop) can run indefinitely, consuming resources — always add an explicit iteration/depth cap in production recursive CTEs as a safety bound, regardless of how clean you believe the underlying data is.

Watch execution time and row counts for recursive CTE queries specifically as the underlying hierarchical data grows — a recursive CTE that was instant over a 50-row org chart can become noticeably slower over a 5,000-row one, and it's worth tracking that trend before it becomes a user-facing complaint.

  • Use CTEs for all complex queries in production — much easier to maintain than deeply nested subqueries.

  • Document CTE purpose with inline comments: -- CTE 1: calculate department statistics.

  • Test recursive CTEs with real data including edge cases (root has no manager, leaf has no children).

  • Consider materialized path or closure table for hierarchies > 6 levels deep — recursive CTE can be slow.

  • For true graph traversal at scale (not just shallow hierarchies), consider a graph database (Neo4j) or the Apache AGE extension — recursive CTEs get slow on deep, wide graphs.

  • Write a recursive CTE to traverse the employee hierarchy starting from Eve (CEO). Display each employee with their level (0=CEO, 1=direct report, etc.) and full path (Eve > Alice > Carol).

  • Find all employees who report to Alice (directly or indirectly) at any level using a recursive CTE.

  • Write a writeable CTE that gives 10% raise to all Engineering employees and simultaneously inserts records into a salary_audit table.

  • Generate a complete calendar for 2024 (all 366 days) using a recursive CTE. Then rewrite using generate_series() and compare.

  • CTE (WITH clause): named temporary result; improves readability; reusable in the same query.

  • Recursive CTE: ANCHOR UNION ALL RECURSIVE — traverses hierarchies/graphs without application loops.

  • PostgreSQL 12+: CTEs inlined by default; use AS MATERIALIZED to force single evaluation.

  • CYCLE detection: prevents infinite loops in recursive CTEs (PostgreSQL 14+).

  • Writeable CTE: DML (INSERT/UPDATE/DELETE) + RETURNING — atomic multi-step DML in one statement.

  • Always UNION ALL (not UNION) in recursive member — performance and correctness.

  • For deep hierarchies (> 6 levels): consider materialized path or closure table pattern.

Want a visual for this concept?

Generate a diagram tailored to “CTEs — Common Table Expressions & Recursive CTEs” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Performance — EXPLAIN ANALYZE, Partitioning & Sharding← Back to all SQL chapters