CTEs & WITH Clause
WITH keyword. CTEs make complex queries dramatically more readable — you break one large query into labeled, logical steps that you can reference multiple times.
What Is a CTE?
A CTE is declared before the main SELECT using WITH cte_name AS (...). Think of it as giving a subquery a name so you can reference it cleanly.
-- Basic CTE WITH top_students AS ( SELECT name, grade, age FROM students WHERE grade IN ('A', 'A+') ) SELECT * FROM top_students ORDER BY age DESC;
The CTE top_students is defined once and queried in the final SELECT. It only exists for the duration of this single query — it is never stored on disk.
Multiple CTEs
You can chain multiple CTEs in one WITH block — separate them with commas. Each CTE can reference earlier ones defined in the same block.
WITH enrolled AS ( SELECT DISTINCT student_id FROM enrollments ), unenrolled AS ( SELECT id, name FROM students WHERE id NOT IN (SELECT student_id FROM enrolled) ) SELECT * FROM unenrolled;
The second CTE unenrolled references the first CTE enrolled — this is much cleaner than deeply nested subqueries.
CTE vs Subquery
Both CTEs and subqueries produce temporary result sets — but they have different strengths:
CTE with Aggregation
CTEs shine when you need to aggregate data in one step and filter on the result in the next — something WHERE cannot do directly.
WITH grade_stats AS ( SELECT grade, COUNT(*) AS total, AVG(age) AS avg_age FROM students GROUP BY grade ), above_avg AS ( SELECT * FROM grade_stats WHERE avg_age > 16.5 ) SELECT * FROM above_avg ORDER BY total DESC;
Recursive CTE
A recursive CTE references itself, making it ideal for hierarchical data like org charts, category trees, or bill-of-materials. It uses WITH RECURSIVE and always has a base case and a recursive part joined by UNION ALL.
WITH RECURSIVE org_chart AS ( -- Base case: top-level employees (no manager) SELECT id, name, manager_id, 1 AS level FROM employees WHERE manager_id IS NULL UNION ALL -- Recursive: add one level deeper each iteration SELECT e.id, e.name, e.manager_id, oc.level + 1 FROM employees e JOIN org_chart oc ON e.manager_id = oc.id ) SELECT * FROM org_chart ORDER BY level, name;