SQL CTE (WITH Clause) Explained — From Basics to Recursive Queries

Published: September 11, 2026 | Updated: September 11, 2026

A Common Table Expression (CTE) is a named, temporary result set you define with the WITH keyword that lives for exactly one statement. Think of it as a variable for a query: you give a subquery a meaningful name once, then reference that name wherever a table would go. The result is SQL that reads top-to-bottom like prose instead of inside-out like a matryoshka doll of nested subqueries.

CTEs have been in the SQL standard since 1999, and every major database now supports them — PostgreSQL since 8.4, MySQL since 8.0, plus SQL Server, Oracle, and SQLite. This guide covers the full arc: basic WITH syntax, why CTEs beat nested subqueries on readability, chaining multiple CTEs, recursive CTEs with complete runnable examples (number sequences and org-chart trees), how MySQL and PostgreSQL differ under the hood, and the pitfalls that catch people in production.

Table of Contents
  1. 1. What Is a CTE?
  2. 2. Basic CTE Syntax
  3. 3. CTE vs Nested Subquery — the Readability Case
  4. 4. Chaining Multiple CTEs
  5. 5. Recursive CTEs: Sequences and Trees
  6. 6. CTE vs Temp Table vs View — Which to Use
  7. 7. MySQL 8.0+ vs PostgreSQL: Support and Behavior
  8. 8. Common CTE Pitfalls
  9. FAQ

1. What Is a CTE?

A CTE is a named subquery defined at the top of a statement, before the main query runs. Its defining traits:

The SQL standard introduced CTEs in SQL:1999, and the recursive variant is what made them genuinely powerful — it is the only standard way to walk hierarchical or graph-like data (org charts, category trees, bill-of-materials) inside a single query.

2. Basic CTE Syntax

The shape is always the same: WITH, a name, an optional column list, AS, a parenthesized subquery, then the main statement.

WITH high_value_orders AS (
    SELECT user_id, order_id, total
    FROM orders
    WHERE total > 1000
      AND status = 'completed'
)
SELECT u.name, COUNT(h.order_id) AS big_orders, SUM(h.total) AS revenue
FROM users u
JOIN high_value_orders h ON h.user_id = u.id
GROUP BY u.name
ORDER BY revenue DESC
LIMIT 10;

Two syntax details worth knowing:

💡 Pro Tip: Do not put a semicolon between the CTE and the main query — they are one statement. A stray ; after the closing paren is the most common syntax error people hit when converting subqueries to CTEs.

3. CTE vs Nested Subquery — the Readability Case

CTEs and subqueries can express the same logic. The difference is the order a human reads them. Subqueries nest inside-out: you hit the innermost query last but must understand it first. CTEs stack top-to-bottom: each named step is defined before it is used.

Here is the same analytical question both ways — "For each department, compare every employee's salary against that department's average, but only for departments that spend more than $500k on salaries."

Version A — nested subqueries

SELECT e.name,
       e.department_id,
       e.salary,
       d.avg_salary,
       e.salary - d.avg_salary AS diff
FROM employees e
JOIN (
    SELECT department_id, AVG(salary) AS avg_salary, SUM(salary) AS total_payroll
    FROM employees
    GROUP BY department_id
    HAVING SUM(salary) > 500000
) d ON d.department_id = e.department_id
WHERE e.salary > (
    SELECT AVG(salary)
    FROM employees e2
    WHERE e2.department_id = e.department_id
)
ORDER BY diff DESC;

Version B — the same query with CTEs

WITH dept_stats AS (
    SELECT department_id,
           AVG(salary) AS avg_salary,
           SUM(salary) AS total_payroll
    FROM employees
    GROUP BY department_id
    HAVING SUM(salary) > 500000
),
above_average_employees AS (
    SELECT e.*
    FROM employees e
    JOIN dept_stats d ON d.department_id = e.department_id
    WHERE e.salary > d.avg_salary
)
SELECT a.name,
       a.department_id,
       a.salary,
       d.avg_salary,
       a.salary - d.avg_salary AS diff
FROM above_average_employees a
JOIN dept_stats d ON d.department_id = a.department_id
ORDER BY diff DESC;

Same semantics, but version B has three properties version A lacks:

Performance-wise, the two are usually equivalent — optimizers flatten both into the same plan (see section 7 for the important exceptions). Choose CTEs for the human reading the query at 2 a.m. during an incident.

4. Chaining Multiple CTEs

A single WITH clause can define several CTEs separated by commas, and each one may reference any CTE defined before it. This turns a monster query into a named pipeline — each stage does one thing, and the final SELECT just assembles the result.

WITH
-- Stage 1: monthly revenue per product category
monthly_revenue AS (
    SELECT DATE_TRUNC('month', o.created_at) AS month,
           p.category,
           SUM(o.total) AS revenue
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.id
    JOIN products p ON p.id = oi.product_id
    WHERE o.status = 'completed'
    GROUP BY 1, 2
),
-- Stage 2: previous month's revenue for each row (self-join on stage 1)
with_previous AS (
    SELECT mr.month,
           mr.category,
           mr.revenue,
           LAG(mr.revenue) OVER (
               PARTITION BY mr.category ORDER BY mr.month
           ) AS prev_revenue
    FROM monthly_revenue mr
),
-- Stage 3: month-over-month growth
growth AS (
    SELECT month,
           category,
           revenue,
           ROUND((revenue - prev_revenue) / NULLIF(prev_revenue, 0) * 100, 1)
               AS growth_pct
    FROM with_previous
    WHERE prev_revenue IS NOT NULL
)
-- Final: report the 5 biggest surges
SELECT * FROM growth
ORDER BY growth_pct DESC
LIMIT 5;

Rules that govern the chain:

Long WITH Chains Get Messy Fast

Multi-stage CTE queries can span hundreds of lines. Paste yours into our formatter to get clean, consistent indentation before you commit it.

Open SQL Format Tool →

5. Recursive CTEs: Sequences and Trees

A recursive CTE references itself. It is the SQL answer to "give me all descendants" or "generate a series" — problems that previously required stored procedures or application-side loops.

The anatomy

Every recursive CTE has exactly two parts joined by UNION ALL (or UNION):

  1. Anchor member — a normal query that produces the starting rows. Runs once.
  2. Recursive member — a query that references the CTE itself. On each iteration it sees only the rows produced by the previous iteration (the "working table"), and its output becomes the input for the next iteration. Recursion stops when an iteration produces zero rows.
WITH RECURSIVE cte_name (columns) AS (
    -- anchor: SELECT ... (no reference to cte_name)
    UNION ALL
    -- recursive member: SELECT ... FROM cte_name JOIN base_table ...
    -- must eventually produce zero rows, or it runs forever
)
SELECT * FROM cte_name;

In PostgreSQL and MySQL 8.0+, you must write the RECURSIVE keyword even for a single self-referencing CTE. (SQL Server infers it and has no such keyword — a classic portability trap.)

Example 1 — generate a number sequence

The simplest recursion: count from 1 to 10. Useful in practice for filling gaps in time-series reports or generating test rows.

WITH RECURSIVE numbers (n) AS (
    -- Anchor: start at 1
    SELECT 1
    UNION ALL
    -- Recursive step: take previous iteration's rows, add 1, stop past 10
    SELECT n + 1 FROM numbers WHERE n < 10
)
SELECT n FROM numbers;

Output: 1, 2, 3, ..., 10. Here is exactly how one engine executes it: the anchor puts {1} into the working table. Iteration 1 runs the recursive member against {1}, passes the n < 10 filter, and emits {2} — which becomes both output and the input for iteration 2. This continues until an iteration reads {10}: the filter 10 < 10 is false, the member emits zero rows, and the recursion terminates. The final result is the anchor plus every iteration's output. In practice this pattern generates date spines too — join the sequence against a start date (SELECT DATE '2026-09-01' + (n - 1) in PostgreSQL, DATE_ADD('2026-09-01', INTERVAL n-1 DAY) in MySQL) to fill gaps in time-series reports.

Example 2 — walk an org chart (hierarchy traversal)

The canonical real-world use. Given an employees table where each row has a manager_id pointing to another row, find everyone reporting under one executive, at any depth, with their level in the tree:

-- Setup (runnable as-is in MySQL 8+ and PostgreSQL)
CREATE TABLE employees (
    id          INT PRIMARY KEY,
    name        VARCHAR(50),
    manager_id  INT NULL          -- NULL means top of the tree
);

INSERT INTO employees (id, name, manager_id) VALUES
    (1,  'Alice (CEO)',    NULL),
    (2,  'Bob (CTO)',      1),
    (3,  'Carol (CFO)',    1),
    (4,  'Dave (Eng Mgr)', 2),
    (5,  'Erin (Dev)',     4),
    (6,  'Frank (Dev)',    4),
    (7,  'Grace (SRE)',    2),
    (8,  'Heidi (Fin)',    3),
    (9,  'Ivan (Analyst)', 8);

-- Walk the whole tree from the CEO downward
WITH RECURSIVE org_tree (id, name, manager_id, level, path) AS (
    -- Anchor: the root(s) of the hierarchy
    SELECT id, name, manager_id, 0 AS level, name AS path
    FROM employees
    WHERE manager_id IS NULL

    UNION ALL

    -- Recursive member: find direct reports of the previous iteration's rows
    SELECT e.id, e.name, e.manager_id,
           t.level + 1,
           CONCAT(t.path, ' > ', e.name)
    FROM employees e
    JOIN org_tree t ON t.id = e.manager_id
)
SELECT level, name, path
FROM org_tree
ORDER BY level, name;

Output:

level | name         | path
------+--------------+------------------------------------------
0     | Alice (CEO)  | Alice (CEO)
1     | Bob (CTO)    | Alice (CEO) > Bob (CTO)
1     | Carol (CFO)  | Alice (CEO) > Carol (CFO)
2     | Dave (Eng)   | Alice (CEO) > Bob (CTO) > Dave (Eng Mgr)
2     | Grace (SRE)  | Alice (CEO) > Bob (CTO) > Grace (SRE)
2     | Heidi (Fin)  | Alice (CEO) > Carol (CFO) > Heidi (Fin)
3     | Erin (Dev)   | ... > Dave (Eng Mgr) > Erin (Dev)
3     | Frank (Dev)  | ... > Dave (Eng Mgr) > Frank (Dev)
4     | Ivan (Anlst) | ... > Heidi (Fin) > Ivan (Analyst)

Why this works: the anchor seeds the working table with the CEO. Each iteration joins employees.manager_id against the previous iteration's id, producing the next level down. When the leaf employees (Erin, Frank, Ivan) have no reports, the join returns zero rows and recursion terminates naturally. The level counter and the accumulated path string fall out for free — you can WHERE level <= 2 to cap depth, or filter path LIKE '%Bob%' to grab one subtree.

To walk upward instead (find all managers above one employee), swap the anchor and the join direction:

WITH RECURSIVE chain (id, name, manager_id) AS (
    SELECT id, name, manager_id FROM employees WHERE id = 5   -- start at Erin
    UNION ALL
    SELECT e.id, e.name, e.manager_id
    FROM employees e
    JOIN chain c ON c.manager_id = e.id                        -- hop to the manager
)
SELECT * FROM chain;
💡 Pro Tip: Use UNION instead of UNION ALL in the recursive member and the engine dedupes each iteration, which terminates recursion even when your data has cycles (A reports to B reports to A). It costs a dedup pass per iteration, but it is cheap insurance against dirty data turning into an infinite loop. MySQL supports UNION DISTINCT in recursive CTEs since 8.0.19+.

6. CTE vs Temp Table vs View — Which to Use

These three get confused because all three "name a query result." They differ in lifetime, storage, and reusability:

CriterionCTETemp TableView
LifetimeOne statementSession / transactionPermanent schema object
Physically stored?No (may be materialized transiently)Yes (temp storage)No — unless materialized view
Own indexes?NoYesNo (materialized: yes)
Recursive traversalYes (WITH RECURSIVE)Only via loops in proceduresNo
Reusable across statementsNoYesYes
Needs CREATE privilegesNoUsually no (temp schema)Yes
Best forReadable one-shot multi-step queriesLarge intermediate results reused by several queries; huge ETL stepsStable, shared query logic with access control

Practical heuristics from production work:

7. MySQL 8.0+ vs PostgreSQL: Support and Behavior

FeatureMySQLPostgreSQL
Non-recursive CTEs8.0+ (2018); none in 5.78.4+ (2009)
Recursive CTEs8.0+, requires WITH RECURSIVE8.4+, requires WITH RECURSIVE
Recursion depth limitcte_max_recursion_depth, default 1000None (bounded by memory / cancellation)
Materialization controlOptimizer decides; derived_merge switch applies12+: inlined unless referenced twice or marked MATERIALIZED; 11 and earlier: always materialized
Data-modifying CTEs (INSERT/UPDATE/DELETE inside WITH)No — SELECT onlyYes
CTE in subqueries / viewsYes (8.0)Yes

The behavioral difference that matters most is materialization:

Recursive CTE execution is nearly identical in both engines: iterate, feed the previous result back in, stop on empty. MariaDB mirrors MySQL's syntax from 10.2 onward, so most MySQL 8 CTE code runs unchanged there.

8. Common CTE Pitfalls

Pitfall 1 — Assuming MySQL materializes CTEs (it doesn't)

People port PostgreSQL habits to MySQL and assume a CTE referenced three times is computed once. It may not be. MySQL's optimizer can merge non-materializable CTEs into the outer query, and merged references can cause the underlying logic to run per outer row. Symptom: a query that got slower after you "cleaned it up" into CTEs. Diagnosis: EXPLAIN FORMAT=TREE and look at whether the CTE shows up once. Fix for pathological cases: move the shared logic into a temp table, or restructure so the CTE is referenced once.

Pitfall 2 — Hitting the recursion depth limit in MySQL

-- MySQL: default cap is 1000 iterations
ERROR 3636 (HY000): Recursive query aborted after 1001 iterations.
Try increasing CTE_MAX_RECURSION_DEPTH to a higher value.

-- Raise it for the session (max 4294967295):
SET SESSION cte_max_recursion_depth = 100000;

-- Or per-query with an optimizer hint:
WITH RECURSIVE deep_tree AS (...)
SELECT /*+ SET_VAR(cte_max_recursion_depth = 1000000) */ * FROM deep_tree;

Generating 50k date rows or walking a 2000-level category tree will fail out of the box on MySQL. The limit is a feature — it catches runaway recursions from cyclic data — but you need to know it exists before your ETL job dies at row 1001 on a Friday afternoon.

Pitfall 3 — Missing terminating condition (or cycle) in the data

PostgreSQL has no depth cap, so a manager_id cycle (A→B→A) loops forever, eating memory until you cancel. Defenses: keep a level counter and cap it (WHERE level < 50), track visited ids in an array (WHERE NOT id = ANY(path_ids)), or use UNION instead of UNION ALL so duplicates terminate the walk.

Pitfall 4 — Referencing a CTE from the next statement

WITH recent_orders AS (SELECT * FROM orders WHERE created_at > NOW() - INTERVAL 7 DAY)
SELECT COUNT(*) FROM recent_orders;

SELECT * FROM recent_orders;  -- ERROR: table doesn't exist.
                              -- The CTE died with the previous statement.

If you need the result across statements, that is a temp table job (section 6).

Pitfall 5 — Forgetting the RECURSIVE keyword, or writing it where it isn't needed

MySQL and PostgreSQL both reject a self-referencing CTE that lacks WITH RECURSIVE, while SQL Server rejects WITH RECURSIVE because it doesn't have the keyword at all. Also note: WITH RECURSIVE applies to the whole WITH clause in Postgres/MySQL, so mixing one recursive and several plain CTEs under a single RECURSIVE keyword is legal — the non-recursive ones are simply unaffected.

Pitfall 6 — Column count mismatch between anchor and recursive member

The recursive member's SELECT must produce exactly the same number of columns, in compatible types, as the anchor. The CTE's column names always come from the anchor (or the explicit column list). If you add a column to the anchor during refactoring, update the recursive member too — the error message ("Each UNION clause must have the same number of columns") does not tell you which member drifted.

FAQ — SQL CTEs

Does MySQL support CTEs?

Yes, since MySQL 8.0 (April 2018), both non-recursive and recursive CTEs. MySQL 5.7 and older have no WITH support; use derived tables (subqueries in FROM) instead. MariaDB supports CTEs from 10.2.

Are CTEs faster than subqueries?

Generally neither faster nor slower, they are a readability tool. MySQL merges or materializes a CTE the same way it handles a derived table. PostgreSQL 12+ inlines single-use CTEs into the plan, while PostgreSQL 11 and earlier always materialized them, which could block predicate pushdown. Always confirm with EXPLAIN.

What is the recursion depth limit for recursive CTEs?

MySQL: cte_max_recursion_depth, default 1000 iterations, raise it per session or with a SET_VAR hint. PostgreSQL: no built-in limit, recursion runs until the working table is empty. SQL Server uses the MAXRECURSION hint, default 100.

Can I use INSERT, UPDATE, or DELETE inside a CTE?

In PostgreSQL, yes: data-modifying CTEs are supported and run atomically with the main statement. In MySQL, no: CTE bodies must be SELECT, TABLE or VALUES statements, although a CTE can feed the main statement's INSERT ... SELECT or UPDATE ... JOIN.

Can one CTE reference another CTE?

Yes. A CTE can reference any CTE defined earlier in the same WITH clause, which is how you build multi-stage query pipelines. It cannot reference a later CTE and, outside WITH RECURSIVE, it cannot reference itself.

Is a CTE the same as a view or a temporary table?

No. A view and a temporary table live in the database catalog and persist between statements, while a CTE exists only for the single statement that declares it. A view can be referenced by many queries and other views; a CTE has to be re-declared each time and may be materialized more than once. Use a CTE for one-off readability, a view when several queries share a definition, and a temporary table when you need indexes on a large intermediate result.