SQL CTEs: Write Clearer Queries with Common Table Expressions
Transform complex nested queries into readable, maintainable SQL. Learn 6 powerful CTE patterns including recursive CTEs for hierarchical data.
Why CTEs Make Your SQL Better
Complex queries with multiple subqueries quickly become unreadable. You end up with deeply nested SELECT statements where you lose track of what each level does. Debugging these queries is painful, and modifying them later is risky. CTEs solve this by letting you break complex logic into named, sequential steps.
Common Table Expressions (CTEs) use the WITH keyword to define temporary named result sets at the beginning of your query. Instead of nesting subqueries inside subqueries, you write each step at the top with a descriptive name. The main query then references these names, making the logic flow clear and easy to follow.
Beyond readability, CTEs enable recursive queries for hierarchical data. Organization charts, bill of materials, category trees, and other parent-child relationships of unknown depth are natural fits for recursive CTEs. Without them, you'd need procedural code or awkward self-joins limited to fixed depths.
This guide covers six essential CTE patterns that handle common scenarios: simplifying complex queries, chaining transformations, navigating hierarchies, and optimizing repeated calculations. Understanding CTEs elevates your SQL from functional to maintainable.
Common Mistakes with CTEs
Forgetting the Comma Between Multiple CTEs
When defining multiple CTEs, separate them with commas, not additional WITH keywords. It's WITH cte1 AS (...), cte2 AS (...), not WITH cte1 AS (...) WITH cte2 AS (...). This syntax error trips up beginners who expect each CTE to start with WITH.
Creating Infinite Loops in Recursive CTEs
Recursive CTEs without proper termination conditions run forever (or until hitting recursion limits). Always ensure your recursive query eventually produces no rows. Test with small datasets first and include safety conditions to prevent runaway recursion.
Using CTEs When Views Would Be Better
If you're writing the same CTE in multiple queries, create a view instead. CTEs are query-scoped and don't persist. Views are reusable database objects. Use CTEs for query-specific logic, views for shared logic across multiple queries.
Expecting CTEs to Always Improve Performance
CTEs don't guarantee better performance. Most databases treat them like subqueries. While they can help when you reference the same result set multiple times, the optimizer may inline them anyway. Use CTEs for readability first, performance second. Always verify with EXPLAIN plans.
Referencing CTEs in the Wrong Order
You can only reference CTEs defined earlier in the WITH clause. If cte2 references cte1, define cte1 first. Order matters when chaining CTEs. The database evaluates them sequentially, so forward references cause errors.
6 Essential CTE Patterns
These patterns demonstrate how CTEs simplify complex queries and enable powerful recursive operations.
Basic CTE for Query Clarity
Simplify complex queries by breaking them into readable steps
Example:
WITH recent_orders AS (
SELECT customer_id, order_id, total_amount, order_date
FROM orders
WHERE order_date >= '2025-01-01'
)
SELECT
c.customer_name,
COUNT(ro.order_id) as order_count,
SUM(ro.total_amount) as total_spent
FROM customers c
JOIN recent_orders ro ON c.customer_id = ro.customer_id
GROUP BY c.customer_id, c.customer_name
ORDER BY total_spent DESC;When to Use:
CTEs (Common Table Expressions) make queries more readable by giving names to subqueries. Instead of nested subqueries, you define temporary result sets at the top. The query becomes self-documenting - the name 'recent_orders' tells you exactly what the data represents.
Multiple CTEs for Complex Logic
Chain multiple CTEs together for step-by-step transformations
Example:
WITH active_customers AS (
SELECT customer_id, customer_name
FROM customers
WHERE status = 'active'
),
customer_orders AS (
SELECT
ac.customer_id,
ac.customer_name,
COUNT(o.order_id) as order_count,
SUM(o.total_amount) as total_spent
FROM active_customers ac
LEFT JOIN orders o ON ac.customer_id = o.customer_id
GROUP BY ac.customer_id, ac.customer_name
)
SELECT *
FROM customer_orders
WHERE order_count >= 5 OR total_spent > 1000
ORDER BY total_spent DESC;When to Use:
Chain multiple CTEs by separating them with commas. Each CTE can reference previous ones, building complex logic step by step. This approach is much clearer than nested subqueries and easier to debug - you can comment out the final SELECT and test intermediate CTEs.
Recursive CTE for Hierarchical Data
Navigate tree structures like org charts or category hierarchies
Example:
WITH RECURSIVE employee_hierarchy AS (
-- Anchor: start with top-level employees
SELECT employee_id, employee_name, manager_id, 1 as level
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive: get employees who report to current level
SELECT e.employee_id, e.employee_name, e.manager_id, eh.level + 1
FROM employees e
JOIN employee_hierarchy eh ON e.manager_id = eh.employee_id
)
SELECT * FROM employee_hierarchy
ORDER BY level, employee_name;When to Use:
Recursive CTEs solve hierarchical problems like org charts, bill of materials, or category trees. The anchor query defines the starting point, then the recursive part keeps adding rows until no more are found. Essential for navigating parent-child relationships of unknown depth.
CTE for Data Transformation Pipeline
Transform data through multiple stages in one query
Example:
WITH sales_data AS (
SELECT
product_id,
SUM(quantity) as total_quantity,
SUM(quantity * price) as total_revenue
FROM order_items
GROUP BY product_id
),
enriched_data AS (
SELECT
p.product_name,
p.category,
sd.total_quantity,
sd.total_revenue,
sd.total_revenue / NULLIF(sd.total_quantity, 0) as avg_price
FROM sales_data sd
JOIN products p ON sd.product_id = p.product_id
),
categorized_data AS (
SELECT
*,
CASE
WHEN total_revenue > 10000 THEN 'High Performer'
WHEN total_revenue > 5000 THEN 'Medium Performer'
ELSE 'Low Performer'
END as performance_tier
FROM enriched_data
)
SELECT * FROM categorized_data
ORDER BY total_revenue DESC;When to Use:
Use multiple CTEs to transform data through stages: aggregate raw data, enrich with lookups, add calculated fields, categorize results. This pipeline approach is cleaner than a single complex query and easier to maintain. Each stage has a clear purpose.
CTE for Self-Joins Simplification
Make self-joins more readable with named result sets
Example:
WITH customer_stats AS (
SELECT
customer_id,
COUNT(*) as order_count,
AVG(total_amount) as avg_order_value
FROM orders
GROUP BY customer_id
)
SELECT
cs1.customer_id as customer_1,
cs2.customer_id as customer_2,
ABS(cs1.avg_order_value - cs2.avg_order_value) as value_difference
FROM customer_stats cs1
JOIN customer_stats cs2 ON cs1.customer_id < cs2.customer_id
WHERE ABS(cs1.avg_order_value - cs2.avg_order_value) < 10
ORDER BY value_difference;When to Use:
CTEs make self-joins clearer by defining the dataset once, then joining it to itself with meaningful aliases. Instead of repeating the same subquery twice, you write it once in the CTE. Particularly useful when comparing rows within the same table.
CTE for Performance Optimization
Materialize intermediate results to avoid repeated calculations
Example:
WITH expensive_calculation AS (
SELECT
date_trunc('month', order_date) as month,
customer_id,
COUNT(*) as order_count,
SUM(total_amount) as monthly_total,
AVG(total_amount) as avg_order
FROM orders
WHERE order_date >= '2024-01-01'
GROUP BY date_trunc('month', order_date), customer_id
)
SELECT
month,
SUM(order_count) as total_orders,
AVG(monthly_total) as avg_customer_spend,
COUNT(DISTINCT customer_id) as active_customers
FROM expensive_calculation
GROUP BY month
ORDER BY month;When to Use:
CTEs can improve performance by computing expensive operations once instead of multiple times. While not materialized by default in all databases, they provide a clear point for the query optimizer. Some databases (like PostgreSQL 12+) can inline or materialize CTEs based on performance.
Your First Steps with CTEs
Step 1: Convert a Subquery to a CTE
Take a query with a subquery in FROM or WHERE clause and rewrite it as a CTE. This helps you understand the basic syntax and see how CTEs improve readability.
-- Before: subquery in FROM
SELECT customer_name, order_count
FROM (
SELECT customer_id, COUNT(*) as order_count
FROM orders GROUP BY customer_id
) o
JOIN customers c ON o.customer_id = c.customer_id;
-- After: CTE
WITH order_counts AS (
SELECT customer_id, COUNT(*) as order_count
FROM orders GROUP BY customer_id
)
SELECT c.customer_name, oc.order_count
FROM order_counts oc
JOIN customers c ON oc.customer_id = c.customer_id;Step 2: Chain Multiple CTEs Together
Practice writing queries with 2-3 CTEs that build on each other. This teaches you how to structure complex transformations as a series of clear steps.
WITH step1 AS (
SELECT * FROM orders WHERE order_date >= '2025-01-01'
),
step2 AS (
SELECT customer_id, COUNT(*) as count FROM step1 GROUP BY customer_id
),
step3 AS (
SELECT * FROM step2 WHERE count >= 5
)
SELECT c.customer_name, s.count
FROM step3 s
JOIN customers c ON s.customer_id = c.customer_id;Step 3: Try a Simple Recursive CTE
Start with a basic recursive CTE like generating a series of numbers. Once you understand the anchor and recursive parts, move to real hierarchical data.
-- Generate numbers 1 to 10
WITH RECURSIVE numbers AS (
SELECT 1 as n
UNION ALL
SELECT n + 1 FROM numbers WHERE n < 10
)
SELECT * FROM numbers;Frequently Asked Questions About CTEs
What is the difference between a CTE and a subquery?
CTEs and subqueries are functionally similar - both create temporary result sets. CTEs are written at the top with WITH and can be referenced multiple times in the query. Subqueries are inline and must be repeated if needed multiple times. CTEs are generally more readable, especially for complex queries. Use CTEs when you need to reference the same result set multiple times or when readability matters.
Are CTEs faster than subqueries?
Not necessarily. Most databases treat simple CTEs similarly to subqueries in terms of performance. However, if you reference a CTE multiple times, some databases compute it once rather than executing the same subquery multiple times. The main benefit of CTEs is readability and maintainability, not performance. Always test with EXPLAIN to verify performance.
Can I use CTEs for UPDATE or DELETE statements?
Yes, CTEs work with INSERT, UPDATE, and DELETE, not just SELECT. For example: WITH rows_to_delete AS (SELECT id FROM table WHERE condition) DELETE FROM table WHERE id IN (SELECT id FROM rows_to_delete). This makes complex modifications more readable and testable.
How do recursive CTEs work?
Recursive CTEs have two parts: an anchor query (the starting point) and a recursive query (that references the CTE itself). The database executes the anchor first, then repeatedly executes the recursive part using results from the previous iteration until no new rows are produced. Used for hierarchies, graphs, and iterative calculations.
Can I reference one CTE from another CTE?
Yes, when you define multiple CTEs separated by commas, later CTEs can reference earlier ones. This creates a pipeline where each CTE builds on previous results. The order matters - you can only reference CTEs defined earlier in the WITH clause.
What is the maximum recursion depth for recursive CTEs?
Most databases have a recursion limit to prevent infinite loops. PostgreSQL defaults to unlimited (but uses memory limits). SQL Server defaults to 100. MySQL defaults to 1000. You can usually adjust this with database-specific settings. Always include a termination condition in your recursive query to prevent runaway recursion.
When should I use CTEs instead of views?
Use CTEs for query-specific temporary results that you don't need to reuse across multiple queries. Use views for reusable logic that multiple queries or users need to access. CTEs exist only for the duration of one query. Views are permanent database objects that multiple queries can use.
Can CTEs improve query performance?
CTEs primarily improve readability, not performance. In some cases they can help performance by avoiding repeated calculations when referenced multiple times. However, some databases inline CTEs (treat them like subqueries) which negates this benefit. PostgreSQL 12+ introduced MATERIALIZED CTEs for explicit control over this behavior.
Write Better SQL with CTEs
This guide covers essential CTE patterns. The complete book provides comprehensive SQL training from fundamentals to expert techniques, with hundreds of practical examples.
What You'll Get:
- ✓350+ practical SQL examples covering all skill levels
- ✓140+ SQL interview questions with detailed solutions
- ✓Complete e-commerce database for hands-on practice
- ✓SQL code library with 380+ ready-to-use statements
- ✓2 bonus SQL cheat sheets: Glossary and Dialect Differences
Trusted by developers worldwide
4.8/5 rating - Bestselling SQL book