Posted by Kyle Hankinson July 10, 2025
A Common Table Expression (CTE) is a named temporary result set that exists for the duration of a single query. CTEs make complex queries easier to read, write, and debug.
WITH active_users AS (
SELECT id, name, email
FROM users
WHERE status = 'active'
)
SELECT * FROM active_users WHERE email LIKE '%@company.com';
The WITH clause defines the CTE, and the main query uses it like a table. The CTE only exists for that one statement.
Compare a nested subquery to a CTE:
-- Nested subquery (hard to read)
SELECT d.name, stats.avg_salary
FROM departments d
JOIN (
SELECT department_id, AVG(salary) AS avg_salary
FROM employees
WHERE status = 'active'
GROUP BY department_id
) stats ON d.id = stats.department_id
WHERE stats.avg_salary > 75000;
-- CTE (much clearer)
WITH dept_salaries AS (
SELECT department_id, AVG(salary) AS avg_salary
FROM employees
WHERE status = 'active'
GROUP BY department_id
)
SELECT d.name, ds.avg_salary
FROM departments d
JOIN dept_salaries ds ON d.id = ds.department_id
WHERE ds.avg_salary > 75000;
Chain multiple CTEs with commas:
WITH
active_users AS (
SELECT * FROM users WHERE status = 'active'
),
user_orders AS (
SELECT user_id, COUNT(*) AS order_count, SUM(total) AS total_spent
FROM orders
GROUP BY user_id
)
SELECT au.name, uo.order_count, uo.total_spent
FROM active_users au
JOIN user_orders uo ON au.id = uo.user_id
ORDER BY uo.total_spent DESC;
Each CTE can reference the ones defined before it.
Recursive CTEs solve hierarchical data problems like org charts, category trees, and bill-of-materials.
WITH RECURSIVE org_chart AS (
-- Anchor: start with the CEO (no manager)
SELECT id, name, manager_id, 0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive: find each employee's direct reports
SELECT e.id, e.name, e.manager_id, oc.depth + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT REPEAT(' ', depth) || name AS org_tree, depth
FROM org_chart
ORDER BY depth, name;
WITH RECURSIVE category_path AS (
SELECT id, name, parent_id, name AS path
FROM categories
WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id,
cp.path || ' > ' || c.name
FROM categories c
JOIN category_path cp ON c.parent_id = cp.id
)
SELECT name, path FROM category_path ORDER BY path;
Result: Electronics > Computers > Laptops
Generate a sequence of numbers without a table:
WITH RECURSIVE numbers AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM numbers WHERE n < 100
)
SELECT n FROM numbers;
| Database | Basic CTE | Recursive CTE | Keyword |
|---|---|---|---|
| PostgreSQL | Yes | Yes | WITH RECURSIVE |
| MySQL 8.0+ | Yes | Yes | WITH RECURSIVE |
| SQL Server 2005+ | Yes | Yes | WITH (no RECURSIVE keyword) |
| Oracle 11g+ | Yes | Yes | WITH (no RECURSIVE keyword) |
| SQLite 3.8.3+ | Yes | Yes | WITH RECURSIVE |
SQL Server and Oracle do not use the RECURSIVE keyword — the database detects recursion automatically.
A bug in the recursive condition can cause infinite recursion. Protect against it:
-- PostgreSQL / MySQL: limit depth
WITH RECURSIVE tree AS (
SELECT id, parent_id, 1 AS depth FROM nodes WHERE parent_id IS NULL
UNION ALL
SELECT n.id, n.parent_id, t.depth + 1
FROM nodes n JOIN tree t ON n.parent_id = t.id
WHERE t.depth < 50 -- safety limit
)
SELECT * FROM tree;
-- SQL Server: OPTION (MAXRECURSION)
WITH tree AS (...)
SELECT * FROM tree OPTION (MAXRECURSION 100);
| CTE | Subquery | Temp Table | |
|---|---|---|---|
| Readability | Best | Worst (nested) | Good |
| Reusable in same query | Yes | No | Yes |
| Persists across queries | No | No | Yes |
| Indexable | No | No | Yes |
| Recursive support | Yes | No | No |
About the author — Kyle Hankinson is the founder and sole developer of SQLPro Studio and the Hankinsoft Development suite of database tools. He has been building native macOS and iOS applications since 2010.
Try SQLPro Studio — A powerful database manager for MySQL, PostgreSQL, Microsoft SQL Server, SQLite, Oracle, and Snowflake. Available on macOS, iOS, and Windows.
Download Free Trial View Pricing Compare