By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.
Hyper-Practical, Zero-Fluff Study Guide
What CTEs are:A Common Table Expression (CTE) is a temporary result set you define within a SQL query using the WITH clause. Think of it like a sticky note for your query—you write down a subquery once, give it a name, and reuse it multiple times in the same query. CTEs can be single (one temporary table) or multiple (chained together like Lego blocks).
WITH
Why this matters in production:- Legacy codebases: You inherit a 500-line query with nested subqueries that look like spaghetti. CTEs let you refactor it into readable, modular blocks.- Debugging: Instead of scrolling through a monolithic query, you can isolate and test each CTE independently.- Performance: Some databases (like PostgreSQL) materialize CTEs, meaning they run once and cache the result—saving compute time.- Collaboration: Your teammate can glance at a CTE named customer_churn_risk and instantly understand its purpose, unlike a cryptic subquery.
customer_churn_risk
Real-world scenario:You’re analyzing e-commerce data. Your boss asks: "Show me the top 10 products by revenue, but only for customers who made their first purchase in 2023 and have a lifetime value > $500." Without CTEs, you’d write a nested mess. With CTEs, you break it into: 1. first_time_customers_2023 (filter for first purchase year) 2. high_value_customers (join with lifetime value) 3. product_revenue (aggregate sales) Then join them cleanly.
first_time_customers_2023
high_value_customers
product_revenue
WITH clause The SQL keyword that starts a CTE. Example: sql WITH my_cte AS (SELECT * FROM users WHERE signup_date > '2023-01-01') Production insight: Always put the WITH clause before your main query (e.g., SELECT, INSERT).
sql WITH my_cte AS (SELECT * FROM users WHERE signup_date > '2023-01-01')
SELECT
INSERT
Single CTE One temporary table defined in a WITH clause. sql WITH active_users AS ( SELECT user_id, COUNT(*) as order_count FROM orders WHERE order_date > CURRENT_DATE - INTERVAL '30 days' GROUP BY user_id ) Production insight: Use descriptive names (e.g., active_users vs. cte1). Future you (or your teammate) will thank you.
sql WITH active_users AS ( SELECT user_id, COUNT(*) as order_count FROM orders WHERE order_date > CURRENT_DATE - INTERVAL '30 days' GROUP BY user_id )
active_users
cte1
Multiple CTEs Chain multiple CTEs in one WITH clause, separated by commas. sql WITH active_users AS (...), high_value_users AS ( SELECT * FROM active_users WHERE order_count > 5 ) Production insight: Order matters! Later CTEs can reference earlier ones, but not vice versa.
sql WITH active_users AS (...), high_value_users AS ( SELECT * FROM active_users WHERE order_count > 5 )
Recursive CTEs A CTE that references itself (e.g., for hierarchical data like org charts). sql WITH RECURSIVE org_hierarchy AS ( SELECT id, name, manager_id FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.id, e.name, e.manager_id FROM employees e JOIN org_hierarchy oh ON e.manager_id = oh.id ) Production insight: Recursive CTEs are powerful but dangerous—they can cause infinite loops. Always include a termination condition (e.g., WHERE depth < 10).
sql WITH RECURSIVE org_hierarchy AS ( SELECT id, name, manager_id FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.id, e.name, e.manager_id FROM employees e JOIN org_hierarchy oh ON e.manager_id = oh.id )
WHERE depth < 10
Materialization Some databases (PostgreSQL, SQL Server) cache CTE results, while others (MySQL < 8.0) re-run them every time they’re referenced. Production insight: If performance is critical, test whether your database materializes CTEs. If not, consider a temporary table instead.
CTEs vs. Subqueries
CTE: Defined once, referenced multiple times (cleaner, modular). Production insight: Use CTEs for complex logic or repeated calculations. Use subqueries for simple, one-off filters.
CTEs vs. Temporary Tables
Temporary table: Persists for the session (useful for multi-step ETL). Production insight: Use CTEs for ad-hoc analysis. Use temp tables for batch processing (e.g., loading data into a dashboard).
CTEs in Views You can use CTEs inside a CREATE VIEW statement to make complex logic reusable. sql CREATE VIEW customer_segmentation AS WITH high_value AS (...), at_risk AS (...) SELECT * FROM high_value UNION ALL SELECT * FROM at_risk; Production insight: Views with CTEs are self-documenting—great for sharing logic across teams.
CREATE VIEW
sql CREATE VIEW customer_segmentation AS WITH high_value AS (...), at_risk AS (...) SELECT * FROM high_value UNION ALL SELECT * FROM at_risk;
CREATE TABLE orders ( order_id INT, user_id INT, order_date DATE, amount DECIMAL(10,2) );
-- Insert sample data INSERT INTO users VALUES (1, 'Alice', '2023-01-15'), (2, 'Bob', '2022-11-03'), (3, 'Charlie', '2023-02-20');
INSERT INTO orders VALUES (101, 1, '2023-01-20', 50.00), (102, 1, '2023-02-10', 75.50), (103, 2, '2022-12-01', 30.00), (104, 3, '2023-03-05', 200.00); ```
We’ll break this into 3 CTEs: 1. users_2023: Filter for 2023 signups.2. user_lifetime_value: Calculate total spend per user.3. high_value_users: Filter for LTV > $100.
users_2023
user_lifetime_value
high_value_users
WITH users_2023 AS ( SELECT user_id, name FROM users WHERE signup_date >= '2023-01-01' ) SELECT * FROM users_2023;
Expected output:
user_id | name | --------|---------| 1 | Alice | 3 | Charlie |
WITH users_2023 AS ( SELECT user_id, name FROM users WHERE signup_date >= '2023-01-01' ), user_lifetime_value AS ( SELECT o.user_id, SUM(o.amount) AS total_spend FROM orders o GROUP BY o.user_id ) SELECT * FROM user_lifetime_value;
user_id | total_spend | --------|-------------| 1 | 125.50 | 2 | 30.00 | 3 | 200.00 |
WITH users_2023 AS ( SELECT user_id, name FROM users WHERE signup_date >= '2023-01-01' ), user_lifetime_value AS ( SELECT o.user_id, SUM(o.amount) AS total_spend FROM orders o GROUP BY o.user_id ), high_value_users AS ( SELECT u.user_id, u.name, ulv.total_spend FROM users_2023 u JOIN user_lifetime_value ulv ON u.user_id = ulv.user_id WHERE ulv.total_spend > 100 ) SELECT * FROM high_value_users;
user_id | name | total_spend | --------|---------|-------------| 1 | Alice | 125.50 | 3 | Charlie | 200.00 |
snake_case
active_users_last_30_days
-- This CTE calculates 30-day rolling revenue
SELECT *
WHERE
-- Good: Filter in the CTE WITH recent_orders AS ( SELECT * FROM orders WHERE order_date > '2023-01-01' ) SELECT * FROM recent_orders; - Test materialization: In PostgreSQL, use `EXPLAIN ANALYZE` to check if CTEs are materialized.sql EXPLAIN ANALYZE WITH cte AS (SELECT * FROM users) SELECT * FROM cte; `` Look forCTE Scan(materialized) vs.Subquery Scan` (not materialized).
- Test materialization: In PostgreSQL, use `EXPLAIN ANALYZE` to check if CTEs are materialized.
`` Look for
(materialized) vs.
LIMIT
sql WITH test_cte AS (SELECT * FROM huge_table LIMIT 100) SELECT * FROM test_cte;
DISTINCT
GROUP BY
-- LTV = sum(order_amount) * 0.8 (20% churn rate)
Add CTE for customer segmentation
relation "cte_name" does not exist
EXPLAIN ANALYZE
column "user_id" specified more than once
user_id AS customer_id
SELECT * FROM (WITH cte AS (SELECT * FROM users) SELECT * FROM cte);
✅ WITH cte AS (SELECT * FROM users) SELECT * FROM cte;
WITH cte AS (SELECT * FROM users) SELECT * FROM cte;
Performance questions: "You have a query with 3 CTEs. The first CTE filters a large table. Where should you add a WHERE clause to optimize performance?"
✅ Inside the first CTE (to reduce data early).
Recursive CTEs: "Which keyword is required for a recursive CTE?"
✅ WITH RECURSIVE (not just WITH).
WITH RECURSIVE
CTEs vs. subqueries: "When should you use a CTE instead of a subquery?"
sql WITH cte AS (SELECT user_id, user_id FROM users) -- Error: duplicate column
"You need to calculate the 7-day rolling average of daily sales. Which approach is most efficient?" - ❌ Subquery in SELECT (recalculates for every row).- ❌ Temporary table (overkill for a single query).- ✅ CTE (clean, modular, and can be materialized).
Challenge:Using the users and orders tables from earlier, write a query that: 1. Finds users who made more than 1 order in 2023.2. Calculates their average order value.3. Returns the results sorted by average order value (highest first).
users
orders
Solution:
WITH users_2023 AS ( SELECT user_id, name FROM users WHERE signup_date >= '2023-01-01' ), user_order_counts AS ( SELECT o.user_id, COUNT(*) AS order_count, AVG(o.amount) AS avg_order_value FROM orders o JOIN users_2023 u ON o.user_id = u.user_id WHERE o.order_date >= '2023-01-01' GROUP BY o.user_id HAVING COUNT(*) > 1 ) SELECT * FROM user_order_counts ORDER BY avg_order_value DESC;
Why it works:- users_2023 filters for 2023 signups.- user_order_counts joins with orders, groups by user, and filters for >1 order.- The final query sorts by avg_order_value.
user_order_counts
avg_order_value
WITH cte_name AS (SELECT * FROM table) SELECT * FROM cte_name;
WITH cte1 AS (...), cte2 AS (...) SELECT * FROM cte1 JOIN cte2 ON ...;
WITH RECURSIVE cte AS (SELECT ... UNION ALL SELECT ... FROM cte)
EXPLAIN ANALYZE WITH cte AS (...) SELECT * FROM cte;
CREATE VIEW my_view AS WITH cte AS (...) SELECT * FROM cte;
Join 4M+ learners. Unlock unlimited quizzes, wrong-answer tracking, flashcards + reminders, study guides, and 1-on-1 challenges.