Building with Multiple CTEs
Progress Tracking
Log in to save this lesson and continue from where you left off.
Each CTE builds on the previous ones. You can trace the logic step by step. When something’s wrong, you can run each CTE independently to find the problem.
Syntax for Multiple CTEs
Multiple CTEs are separated by commas. Only one WITH keyword at the beginning:
WITH first_cte AS (
SELECT ...
),
second_cte AS (
SELECT ...
FROM first_cte
...
),
third_cte AS (
SELECT ...
FROM second_cte
...
)
SELECT *
FROM third_cte;Each CTE can reference any CTE defined before it, but not after. Order matters.
Step-by-Step Analysis Workflow
When facing a complex problem, break it into steps:
- What data do I need first? (first
CTE) - What calculation depends on that? (second
CTE) - What filtering or joining comes next? (third
CTE) - What’s the final output? (main
SELECT)
This mirrors how you’d solve the problem on paper. Each CTE is a checkpoint you can verify.
Income By Title and Gender
Use multiple CTEs to organize your aggregations before the final grouping.
| id | first_name | last_name | age | sex | employee_title | department | salary | target | city | address | manager_id | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 5 | Max | George | 26 | M | Sales | Sales | 1300 | 200 | Max@company.com | California | 2638 Richards Avenue | 1 |
| 13 | Katty | Bond | 56 | F | Manager | Management | 150000 | 0 | Katty@company.com | Arizona | 1 | |
| 11 | Richerd | Gear | 57 | M | Manager | Management | 250000 | 0 | Richerd@company.com | Alabama | 1 | |
| 10 | Jennifer | Dion | 34 | F | Sales | Sales | 1000 | 200 | Jennifer@company.com | Alabama | 13 | |
| 19 | George | Joe | 50 | M | Manager | Management | 100000 | 0 | George@company.com | Florida | 1003 Wyatt Street | 1 |
| worker_ref_id | bonus |
|---|---|
| 1 | 5000 |
| 2 | 3000 |
| 3 | 4000 |
| 1 | 4500 |
| 2 | 3500 |
Find the average total compensation based on employee titles and gender. Total compensation is calculated by adding both the salary and bonus of each employee. However, not every employee receives a bonus so disregard employees without bonuses in your calculation. Employee can receive more than one bonus. Output the employee title, gender (i.e., sex), along with the average total compensation.
Reusing CTEs
Here’s something subqueries can’t do: reference the same result multiple times. CTEs can.
-- CTE reuse: monthly_revenue is defined once but referenced twice
WITH monthly_revenue AS (
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(total_order_cost) AS revenue
FROM orders
GROUP BY DATE_TRUNC('month', order_date)
)
SELECT
curr.month,
curr.revenue AS current_month,
prev.revenue AS previous_month,
curr.revenue - prev.revenue AS change
FROM monthly_revenue curr
LEFT JOIN monthly_revenue prev
ON curr.month = prev.month + INTERVAL '1 month'
ORDER BY curr.month;The monthly_revenue CTE is referenced twice: once for current month, once for previous. Self-joining a CTE is the standard pattern for period-over-period comparisons.
DATE_TRUNC and INTERVAL are PostgreSQL syntax. MySQL uses DATE_FORMAT and DATE_ADD; SQL Server uses DATETRUNC (2022+) or DATEADD. The CTE pattern works the same across dialects.
Debugging Chained CTEs
When a multi-CTE query isn’t working, isolate the problem:
-- Debug Step 1: Run just the first CTE
WITH customer_orders AS (
SELECT
cust_id,
SUM(total_order_cost) AS total_spent
FROM orders
GROUP BY cust_id
)
SELECT *
FROM customer_orders; -- Check this output
-- Debug Step 2: Add the second CTE
WITH customer_orders AS (...),
avg_spend AS (
SELECT AVG(total_spent) AS avg_customer_spend
FROM customer_orders
)
SELECT *
FROM avg_spend; -- Check this outputWork through CTEs one at a time until you find where the data goes wrong.
Debugging CTEs is much easier than debugging nested subqueries. This alone is worth the switch.
Top Cool Votes
Break down the problem into logical steps using chained CTEs.
| business_name | review_id | user_id | stars | review_date | review_text | funny | useful | cool |
|---|---|---|---|---|---|---|---|---|
| AutohausAZ | C4TSIEcazRay0qIRPeMAFg | jlbPUcCRiXlMtarzi9sW5w | 5 | 2011-06-27 | Autohaus is my main source for parts for an old Mercedes that has been in the family since new that I am restoring. The old beater is truly a labor of | 1 | 2 | 1 |
| Citizen Public House | ZZ0paqUsSX-VJbfodTp1cQ | EeCWSGwMAPzwe_c1Aumd1w | 4 | 2013-03-18 | First time in PHX. Friend recommended. Friendly waitstaff. They were understaffed and extremely busy, but we never knew. The short ribs were tende | 0 | 0 | 0 |
| Otto Pizza & Pastry | pF6W5JOPBK6kOXTB58cYrw | JG1Gd2mN2Qk7UpCqAUI-BQ | 5 | 2013-03-14 | LOVE THIS PIZZA! This is now one of my favorite pizza places in phoenix area. My husband i walked into this cute family owned business and were greet | 0 | 0 | 0 |
| Giant Hamburgers | QBddRcflAcXwE2qhsLVv7w | T90ybanuLhAr0_s99GDeeg | 3 | 2009-03-27 | ok, so I tried this place out based on other reviews. I had the cheeseburger with everything, some fries, and a chocolate shake. The burger was okay, | 0 | 1 | 1 |
| Tammie Coe Cakes | Y8UMm_Ng9oEpJbIygoGbZQ | MWt24-6bfv_OHLKhwMQ0Tw | 3 | 2008-08-25 | Overrated. The treats are tasty but certainly not the best I have ever had. I would have rated this a two star but the cakes and cookies are REALLY pr | 1 | 3 | 2 |
Find the `review_text` that received the highest number of `cool` votes. Output the business name along with the review text with the highest number of `cool` votes.
Commenting Your CTEs
Add comments to explain each step. Your future self will thank you.
-- Get raw order data with customer info
WITH order_details AS (...),
-- Aggregate to customer level
customer_totals AS (...),
-- Find the threshold (90th percentile)
threshold AS (...),
-- Filter to top customers
top_customers AS (...)
SELECT *
FROM top_customers;| id | cust_id | order_date | order_details | total_order_cost |
|---|---|---|---|---|
| 1 | 3 | 2019-03-04 | Coat | 100 |
| 2 | 3 | 2019-03-01 | Shoes | 80 |
| 3 | 3 | 2019-03-07 | Skirt | 30 |
| 4 | 7 | 2019-02-01 | Coat | 25 |
| 5 | 7 | 2019-03-10 | Shoes | 80 |
| id | first_name | last_name | city | address | phone_number |
|---|---|---|---|---|---|
| 8 | John | Joseph | San Francisco | 928-386-8164 | |
| 7 | Jill | Michael | Austin | 813-297-0692 | |
| 4 | William | Daniel | Denver | 813-368-1200 | |
| 5 | Henry | Jackson | Miami | 808-601-7513 | |
| 13 | Emma | Isaac | Miami | 808-690-5201 |
The two CTEs find above-average spenders. Add a JOIN to the customers table in the final SELECT to show customer names alongside their totals.
Key Takeaways
- Multiple
CTEsare separated by commas afterWITH - Each
CTEcan reference anyCTEdefined before it - The same
CTEcan be referenced multiple times (unlike subqueries) - Break complex logic into named, testable steps
- Debug by running
CTEsone at a time - Comment your
CTEsto explain each step
What’s Next
You now know how to structure CTEs. Next, we’ll cover specific patterns for common problems: calculating ratios safely, replacing slow correlated subqueries, and knowing when a simple subquery is still the right choice.