Common Table Expressions (CTEs)
A Common Table Expression (CTE) is a named, temporary result set defined with WITH at the start of a statement and usable within that statement only.
Concept
A Common Table Expression (CTE) is a named, temporary result set defined with WITH at the start of a statement and usable within that statement only. Think of it as a named subquery that makes complex SQL readable, step by step.
A recursive CTE refers to itself and is used to traverse hierarchies (org charts, bill of materials, ECU networks) and generate series.
Support: PostgreSQL, SQL Server 2005+, Oracle 9i+ (recursive from 11gR2), SQLite 3.8.3+, MySQL 8.0+, MariaDB 10.2+.
Why It Matters
Breaks complex logic into readable, named steps.
Avoids repeating the same subquery.
Enables recursion, which is otherwise impossible in plain SQL.
Widely used in analytics and data engineering (dbt models are essentially chains of CTEs).
Syntax
WITH cte_name [(col1, col2, ...)] AS (
SELECT ...
),
another_cte AS (
SELECT ... FROM cte_name ...
)
SELECT ...
FROM another_cte;
-- Recursive
WITH RECURSIVE cte_name AS ( -- SQL Server and Oracle: omit the RECURSIVE keyword
anchor_query -- starting rows
UNION ALL
recursive_query -- references cte_name
)
SELECT * FROM cte_name;Example
16.1 Basic CTE 16.2 Multiple CTEs (chained) Each CTE can reference those defined before it.
16.3 CTE with aggregation and comparison Departments above the company average salary:
16.4 Recursive CTE — number and date series How it executes:
Date series for filling gaps (every day of March 2026):
16.5 Recursive CTE — organization hierarchy Asha > Rahul > Priya Asha > Vikram > Meera Asha > Vikram > Neha
All subordinates (direct and indirect) of Vikram — change the anchor:
Walking up the chain (manager chain of Priya):
Safety against cycles (bad data such as A manages B and B manages A): limit depth (WHERE lvl < 20), track visited IDs in a path, use PostgreSQL 14+ CYCLE clause, or SQL Server MAXRECURSION (default 100).
16.6 CTE vs subquery Subquery / derived table High; named, top-down steps Degrades with nesting Reuse within the statement Reference the same CTE multiple times Must repeat the subquery
Usually identical (inlined). PostgreSQL < 12 always materialized CTEs (optimization fence); 12+ inlines unless MATERIALIZED is specified 16.7 CTE vs temporary table Session or transaction
Not persisted (may be materialized in memory/disk during execution) Physically created in tempdb/temp space Yes, can be indexed No separate statistics Yes (helps the optimizer) Reuse across statements
Readable logic within one query, recursion Multi-step processing, large intermediate results reused many times
WITH dept_stats AS (
SELECT dept_id, COUNT(*) AS headcount, AVG(salary) AS avg_salary
FROM employees
WHERE dept_id IS NOT NULL
GROUP BY dept_id
)
SELECT d.dept_name, s.headcount, s.avg_salary
FROM dept_stats s
JOIN departments d ON d.dept_id = s.dept_id
ORDER BY s.avg_salary DESC;WITH order_lines AS (
SELECT o.order_id, o.customer_id, o.status,
oi.quantity * oi.unit_price AS line_total
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
),
valid_lines AS (
SELECT * FROM order_lines WHERE status <> 'CANCELLED'
),
customer_totals AS (
SELECT customer_id, SUM(line_total) AS revenue, COUNT(DISTINCT order_id) AS n_orders
FROM valid_lines
GROUP BY customer_id
)
SELECT c.name, t.n_orders, t.revenue
FROM customer_totals t
JOIN customers c ON c.customer_id = t.customer_id
ORDER BY t.revenue DESC;WITH company AS (
SELECT AVG(salary) AS avg_sal FROM employees
),
dept AS (
SELECT dept_id, AVG(salary) AS dept_avg FROM employees GROUP BY dept_id
)
SELECT dept.dept_id, dept.dept_avg, company.avg_sal
FROM dept CROSS JOIN company
WHERE dept.dept_avg > company.avg_sal;-- PostgreSQL, MySQL 8, SQLite (SQL Server/Oracle: remove RECURSIVE)
WITH RECURSIVE nums (n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM nums WHERE n < 5
)
SELECT n FROM nums;Iteration 0 (anchor): {1}
Iteration 1: {2} (from 1, since 1 < 5)
Iteration 2: {3}
Iteration 3: {4}
Iteration 4: {5}
Iteration 5: {} (5 < 5 is false) → stop
Result = union of all iterations-- PostgreSQL
WITH RECURSIVE days AS (
SELECT DATE '2026-03-01' AS d
UNION ALL
SELECT d + 1 FROM days WHERE d < DATE '2026-03-31'
)
SELECT d FROM days;
-- PostgreSQL shortcut: SELECT generate_series(DATE '2026-03-01', DATE '2026-03-31', INTERVAL '1 day')::DATE;
-- SQL Server: SELECT DATEADD(day, 1, d) ... and OPTION (MAXRECURSION 400) if > 100 levels
-- SQL Server 2022+: GENERATE_SERIES(1, 31)WITH RECURSIVE org AS (
-- Anchor: the root (no manager)
SELECT emp_id, first_name, manager_id, 1 AS lvl,
CAST(first_name AS VARCHAR(500)) AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive step: direct reports of the previous level
SELECT e.emp_id, e.first_name, e.manager_id, o.lvl + 1,
CAST(o.path || ' > ' || e.first_name AS VARCHAR(500))
FROM employees e
JOIN org o ON e.manager_id = o.emp_id
)
SELECT emp_id, first_name, lvl, path
FROM org
ORDER BY path;
-- SQL Server: drop RECURSIVE and use + for concatenation; MySQL: use CONCAT()Asha (CTO)
├── Vikram (Senior Engineer)
│ ├── Neha
│ └── Meera
├── Rahul (Sales Manager)
│ └── Priya
├── Arjun (HR Manager)
└── Kiran (Consultant)WITH RECURSIVE subs AS (
SELECT emp_id, first_name FROM employees WHERE manager_id = 2
UNION ALL
SELECT e.emp_id, e.first_name FROM employees e JOIN subs s ON e.manager_id = s.emp_id
)
SELECT * FROM subs; -- Neha, MeeraWITH RECURSIVE chain AS (
SELECT emp_id, first_name, manager_id FROM employees WHERE emp_id = 5
UNION ALL
SELECT m.emp_id, m.first_name, m.manager_id
FROM employees m JOIN chain c ON m.emp_id = c.manager_id
)
SELECT first_name FROM chain; -- Priya, Rahul, AshaExpected Result
Monthly revenue with month-over-month change, computed step by step:
(Window functions in Module 17 make this simpler with LAG.)
WITH monthly AS (
SELECT EXTRACT(MONTH FROM order_date) AS m, SUM(total_amount) AS revenue
FROM orders
WHERE status <> 'CANCELLED'
GROUP BY EXTRACT(MONTH FROM order_date)
)
SELECT cur.m, cur.revenue, prev.revenue AS prev_revenue,
cur.revenue - prev.revenue AS change
FROM monthly cur
LEFT JOIN monthly prev ON prev.m = cur.m - 1
ORDER BY cur.m;Common Mistakes
Forgetting RECURSIVE in PostgreSQL/MySQL/SQLite, or adding it in SQL Server/Oracle (error).
Recursive CTE without a termination condition (infinite loop until a limit error).
Using UNION instead of UNION ALL in recursion (slower; sometimes used intentionally to stop cycles).
Column type mismatch between anchor and recursive parts (e.g., path string grows beyond the anchor's type) — cast in the anchor.
Referencing a CTE in a later, separate statement (it no longer exists).
Putting a semicolon issue in SQL Server: the statement before WITH must end with ;.
Best Practices
Name CTEs by what they contain (active_customers, monthly_revenue).
One logical step per CTE.
Add depth limits to recursive CTEs.
Use a temp table instead when an intermediate result is large and reused by several statements.
Interview Relevance
High for mid/senior roles: "What is a CTE and why use it?", "CTE vs subquery vs temp table?", "Write a recursive CTE for an org chart", "Generate a date series".
Practice Questions
Using a CTE, list customers whose average order value exceeds 20000.
Using two CTEs, compute total revenue per category and then the category with the highest revenue.
Write a recursive CTE producing numbers 1 to 10 and their squares.
List every employee with their level in the hierarchy.
Using a recursive CTE, count how many people (directly or indirectly) report to Asha.