Module 4: Subqueries & CTEs25 min

Building with Multiple CTEs

Progress Tracking

Log in to save this lesson and continue from where you left off.

Log in

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:

SQL
WITH first_cte AS (
  SELECT ...
),
second_cte AS (
  SELECT ...
  FROM first_cte
  ...
),
third_cte AS (
  SELECT ...
  FROM second_cte
  ...
)
SELECT *
FROM third_cte;
Order matters

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.

Table: sf_employee
idfirst_namelast_nameagesexemployee_titledepartmentsalarytargetemailcityaddressmanager_id
5MaxGeorge26MSalesSales1300200Max@company.comCalifornia2638 Richards Avenue1
13KattyBond56FManagerManagement1500000Katty@company.comArizona1
11RicherdGear57MManagerManagement2500000Richerd@company.comAlabama1
10JenniferDion34FSalesSales1000200Jennifer@company.comAlabama13
19GeorgeJoe50MManagerManagement1000000George@company.comFlorida1003 Wyatt Street1
Table: sf_bonus
worker_ref_idbonus
15000
23000
34000
14500
23500
1
Income By Title and Gender
View solution

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.

Tables: sf_employee, sf_bonus

Reusing CTEs

Here’s something subqueries can’t do: reference the same result multiple times. CTEs can.

PostgreSQL
-- 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.

Dialect differences

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:

SQL
-- 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 output

Work through CTEs one at a time until you find where the data goes wrong.

CTEs are easier to debug

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.

Table: yelp_reviews
business_namereview_iduser_idstarsreview_datereview_textfunnyusefulcool
AutohausAZC4TSIEcazRay0qIRPeMAFgjlbPUcCRiXlMtarzi9sW5w52011-06-27Autohaus 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 of121
Citizen Public HouseZZ0paqUsSX-VJbfodTp1cQEeCWSGwMAPzwe_c1Aumd1w42013-03-18First time in PHX. Friend recommended. Friendly waitstaff. They were understaffed and extremely busy, but we never knew. The short ribs were tende000
Otto Pizza & PastrypF6W5JOPBK6kOXTB58cYrwJG1Gd2mN2Qk7UpCqAUI-BQ52013-03-14LOVE 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 greet000
Giant HamburgersQBddRcflAcXwE2qhsLVv7wT90ybanuLhAr0_s99GDeeg32009-03-27ok, 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, 011
Tammie Coe CakesY8UMm_Ng9oEpJbIygoGbZQMWt24-6bfv_OHLKhwMQ0Tw32008-08-25Overrated. 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 pr132
2
Top Cool Votes
View solution

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.

Tables: yelp_reviews

Commenting Your CTEs

Add comments to explain each step. Your future self will thank you.

SQL
-- 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;
Table: orders
idcust_idorder_dateorder_detailstotal_order_cost
132019-03-04Coat100
232019-03-01Shoes80
332019-03-07Skirt30
472019-02-01Coat25
572019-03-10Shoes80
Table: customers
idfirst_namelast_namecityaddressphone_number
8JohnJosephSan Francisco928-386-8164
7JillMichaelAustin813-297-0692
4WilliamDanielDenver813-368-1200
5HenryJacksonMiami808-601-7513
13EmmaIsaacMiami808-690-5201
3
Try Enriching CTE Results

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.

Tables: orders, customers

Key Takeaways

  • Multiple CTEs are separated by commas after WITH
  • Each CTE can reference any CTE defined before it
  • The same CTE can be referenced multiple times (unlike subqueries)
  • Break complex logic into named, testable steps
  • Debug by running CTEs one at a time
  • Comment your CTEs to 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.