The WITH clause, also known as Common Table Expression (CTE), is a powerful SQL feature that lets you define temporary named result sets within a query. Think of it as creating temporary tables that exist only for the duration of your query.
WITH cte_name AS(SELECT columns
FROMtableWHERE conditions
)SELECT * FROM
Why Use CTEs?
Before CTEs: Subquery Nightmare
SELECT
d.department_name,(SELECT AVG(salary)FROM employees e WHERE e.department_id = d.id)as avg_salary,(SELECTCOUNT(*)FROM employees e WHERE e.department_id = d.id)as employee_count,(SELECT MAX(salary)FROM employees e WHERE e.department_id = d.id)as max_salary
FROM departments d
WHERE(SELECT AVG(salary)FROM employees e WHERE e.department_id = d.id) > 50000
After CTEs: Clean and Readable
WITH department_stats AS(SELECT
department_id,
AVG(salary)as avg_salary,COUNT(*)as employee_count,
MAX(salary)as max_salary
FROM employees
GROUPBY department_id
)SELECT
d.department_name,
ds.avg_salary,
ds.employee_count,
ds.max_salary
FROM departments d
JOIN department_stats ds ON d.id = ds.department_id
WHERE ds.avg_salary > 50000
CTE Syntax Across Databases
CTEs are supported by all major databases with consistent syntax:
Database
Support
Version
PostgreSQL
Full
8.4+
MySQL
Full
8.0+
SQL Server
Full
2005+
Oracle
Full
9i+
SQLite
Full
3.8.3+
Snowflake
Full
All versions
BigQuery
Full
All versions
Basic CTE Examples
Example 1: Simple CTE
WITH active_customers AS(SELECT customer_id, name, email
FROM customers
WHERE status = 'active'AND last_order_date >= CURRENT_DATE - INTERVAL '90 days')SELECT * FROM active_customers ORDERBY
Example 2: CTE with Aggregation
WITH monthly_sales AS(SELECT
DATE_TRUNC('month', order_date)asmonth,
SUM(total_amount)as revenue,COUNT(*)as order_count
FROM orders
WHERE order_date >= '2025-01-01'GROUPBY DATE_TRUNC('month', order_date))SELECTmonth,
revenue,
order_count,
revenue / order_count as avg_order_value
FROM monthly_sales
ORDERBYmonth
Example 3: Multiple CTEs
WITH
high_value_customers AS(SELECT customer_id
FROM orders
GROUPBY customer_id
HAVING SUM(total_amount) > 10000),
recent_orders AS(SELECT customer_id, order_id, order_date, total_amount
FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '30 days')SELECT
c.name,
c.email,
ro.order_date,
ro.total_amount
FROM customers c
JOIN high_value_customers hvc ON c.id = hvc.customer_id
JOIN recent_orders ro ON c.id = ro.customer_id
ORDERBY ro.order_date DESC
Advanced CTE Patterns
Recursive CTEs
Recursive CTEs are powerful for hierarchical data like org charts, category trees, or bill of materials.
Syntax:
WITHRECURSIVE cte_name AS(-- Base case (anchor member)SELECT columns FROMtableWHEREconditionUNIONALL-- Recursive caseSELECT columns FROMtableJOIN cte_name ON relationship
)SELECT * FROM
Example: Organization Hierarchy
WITHRECURSIVE org_tree AS(-- Base case: top-level managers (no manager_id)SELECT
id,
name,
title,
manager_id,1aslevel,
name aspathFROM employees
WHERE manager_id ISNULLUNIONALL-- Recursive case: employees with managersSELECT
e.id,
e.name,
e.title,
e.manager_id,
ot.level + 1,
ot.path || ' > ' || e.name
FROM employees e
JOIN org_tree ot ON e.manager_id = ot.id
)SELECTREPEAT(' ',level - 1) || name as employee,
title,level,pathFROM org_tree
ORDERBYpath
WITH daily_sales AS(SELECT
DATE(order_date)as sale_date,
SUM(total_amount)as daily_total
FROM orders
WHERE order_date >= '2025-01-01'GROUPBY DATE(order_date))SELECT
sale_date,
daily_total,
SUM(daily_total) OVER (ORDERBY sale_date)as running_total
FROM daily_sales
ORDERBY
CTE for Year-over-Year Comparison
WITH current_year AS(SELECT
EXTRACT(MONTHFROM order_date)asmonth,
SUM(total_amount)as revenue
FROM orders
WHERE EXTRACT(YEARFROM order_date) = 2025GROUPBY EXTRACT(MONTHFROM order_date)),
previous_year AS(SELECT
EXTRACT(MONTHFROM order_date)asmonth,
SUM(total_amount)as revenue
FROM orders
WHERE EXTRACT(YEARFROM order_date) = 2024GROUPBY EXTRACT(MONTHFROM order_date))SELECT
cy.month,
cy.revenue as revenue_2025,
py.revenue as revenue_2024,
ROUND((cy.revenue - py.revenue) / py.revenue * 100,2)as yoy_growth_pct
FROM current_year cy
LEFTJOIN previous_year py ON cy.month = py.month
ORDERBY
CTE for Data Deduplication
WITH ranked_records AS(SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY email
ORDERBY created_at DESC)as rn
FROM customers
)SELECT id, email, name, created_at
FROM ranked_records
WHERE rn = 1
CTE for Cohort Analysis
WITH first_purchase AS(SELECT
customer_id,
DATE_TRUNC('month', MIN(order_date))as cohort_month
FROM orders
GROUPBY customer_id
),
cohort_data AS(SELECT
fp.cohort_month,
DATE_TRUNC('month', o.order_date)as order_month,COUNT(DISTINCT o.customer_id)as customers
FROM first_purchase fp
JOIN orders o ON fp.customer_id = o.customer_id
GROUPBY fp.cohort_month, DATE_TRUNC('month', o.order_date))SELECT
cohort_month,
order_month,
customers,
EXTRACT(MONTHFROM AGE(order_month, cohort_month))as months_since_first
FROM cohort_data
ORDERBY cohort_month,
CTE vs Subqueries vs Temporary Tables
Feature
CTE
Subquery
Temp Table
Readability
High
Low
Medium
Reusability in query
Yes
No
Yes
Recursive support
Yes
No
No
Performance
Query optimized
Query optimized
Materialized
Persistence
Query scope
Query scope
Session/Transaction
Best for
Complex logic
Simple, one-time
Multiple queries
CTE Performance Considerations
When CTEs Are Efficient
Reference once - CTE is inlined, no overhead
Recursive operations - Only option for hierarchy traversal
Improving readability - Same performance as subquery
When to Consider Alternatives
Multiple references - Some databases re-execute CTE each time
Large intermediate results - Temp table might be faster
Cross-query reuse - Use views or temp tables
Database-Specific Behavior
PostgreSQL: CTEs are optimization fences by default (materialized). Use WITH cte AS NOT MATERIALIZED for inlining.
WITH active_users ASNOT MATERIALIZED (SELECT * FROM users WHERE status = 'active')SELECT * FROM active_users WHERE created_at > '2025-01-01'
SQL Server: CTEs are always inlined (not materialized).
MySQL 8.0+: CTEs can be materialized or merged based on optimizer decision.
-- Wrong: can't reference cte2 before it's definedWITH cte1 AS(SELECT * FROM cte2),-- Error!
cte2 AS(SELECT * FROMtable)SELECT * FROM cte1;
-- Correct: define in order of dependencyWITH cte2 AS(SELECT * FROMtable),
cte1 AS(SELECT * FROM cte2)SELECT * FROM
2. Forgetting CTE Scope
-- CTE is not available in second queryWITH active_users AS(SELECT * FROM users WHERE status = 'active')SELECT * FROM active_users;
SELECT * FROM active_users; -- Error: active_users doesn't exist here
3. Recursive CTE Without Termination
-- Infinite loop riskWITHRECURSIVE bad_cte AS(SELECT1as n
UNIONALLSELECT n + 1FROM bad_cte -- Never stops!)SELECT * FROM bad_cte;
-- Always add a termination conditionWITHRECURSIVE good_cte AS(SELECT1as n
UNIONALLSELECT n + 1FROM good_cte WHERE n < 100-- Stops at 100)SELECT * FROM
Conclusion
The SQL WITH clause (CTE) is one of the most valuable tools for writing clean, maintainable, and powerful queries. Whether you're simplifying complex logic, traversing hierarchies with recursion, or building multi-step data transformations, CTEs make your SQL more readable and easier to debug.
Key takeaways:
Use CTEs to break complex queries into logical steps
Leverage recursive CTEs for hierarchical data
Combine multiple CTEs for sophisticated analysis
Be aware of database-specific materialization behavior
Want to generate complex CTEs automatically?Try AI2sql - describe your query in plain English and get optimized SQL with proper CTE structure.