CTEs and the WITH Clause — Learn WITH, Multiple CTEs, and Recursive CTEs in Practice
AdvancedCTEMultiple & Recursive CTEsAPI Use CasesPostgreSQL/BigQuery5 questions
QUESTION 1
Basic CTE (WITH Clause) — Replace a Subquery with a CTE for Better Readability
WITHCTEGROUP BYSummary API
Background

A CTE (Common Table Expression) is a temporary named query defined with WITH name AS (...). Extracting logic into a CTE instead of nesting a subquery makes the SQL readable from top to bottom.

WITH cte_name AS (
  -- Write a SELECT statement here (think of it as a temporary table)
  SELECT col1, SUM(col2) AS total
  FROM   some_table
  GROUP BY col1
)
-- Write the main SELECT after the CTE definition
SELECT *
FROM   cte_name
WHERE  total > 10000;
Why use a CTE: A subquery is nested inside the outer SELECT, so deep nesting becomes hard to read. A CTE lets you declare, “define this calculation first, then use it,” making code review and maintenance dramatically easier. It is especially useful for complex SQL in API backends.

CTEs can be used with SELECT, INSERT, UPDATE, and DELETE. This quiz covers SELECT only.

Problem

Using the orders table below, write a CTE query for an API that calculates the total sales, order count, and average order value for each month.

Return only months whose average order value is greater than 40,000.

Tables
▸ orders
order_idorder_monthamount
12024-0130000
22024-0145000
32024-0120000
42024-0255000
52024-0260000
62024-0380000
72024-0315000
82024-0325000
Expected Output
order_monthtotal_salesorder_countavg_order
2024-02115000257500

2024-01 (avg ≈ 31,667) and 2024-03 (avg ≈ 40,000) are excluded because neither satisfies “greater than.” Only 2024-02 has avg > 40000.

QUESTION 2
Chained CTEs — Calculate User Purchase Ranks in Separate Steps
WITHMultiple CTEsJOINRanking APINTILE
Background

You can define multiple comma-separated CTEs after WITH, and each later CTE can reference an earlier one. This expresses a SQL pipeline in which “step ② uses the result of step ①.”

WITH
step1 AS (
  SELECT user_id, SUM(amount) AS total
  FROM   orders
  GROUP BY user_id
),
-- step2 can reference step1 (define consecutive CTEs separated by commas)
step2 AS (
  SELECT *,
         NTILE(4) OVER (ORDER BY total DESC) AS quartile
  FROM   step1   -- Reference the preceding CTE like a table
)
SELECT * FROM step2;
NTILE(n): A window function that divides rows into n groups and assigns bucket numbers from 1 through n. NTILE(4) classifies the top 25% as 1, the next 25% as 2, and so on into quartiles. It is common in customer segmentation analysis.
Problem

Using the users and orders tables below, build an API query that calculates each user's total purchases and ranks users from highest to lowest into quartiles with NTILE.

quartile=1 is the highest rank. Also join user_name from the users table into the final output.

Tables
▸ users
user_iduser_name
U01Alice
U02Bob
U03Carol
U04Dave
U05Eve
U06Frank
U07Grace
U08Hank
▸ orders
order_iduser_idamount
1U01120000
2U0150000
3U0230000
4U03200000
5U0475000
6U0510000
7U0695000
8U0740000
9U08160000
10U0620000
Expected Output
user_iduser_nametotal_amountquartile
U03Carol2000001
U01Alice1700001
U08Hank1600002
U06Frank1150002
U04Dave750003
U07Grace400003
U02Bob300004
U05Eve100004
QUESTION 3
CTE + ROW_NUMBER — Extract the Top N Products in Each Category
CTEROW_NUMBERPARTITION BYTop-N ExtractionRanking API
Background

APIs that retrieve only the top N rows in each category are extremely common. ROW_NUMBER() combined with a CTE is the simplest and most versatile pattern.

WITH ranked AS (
  SELECT *,
    ROW_NUMBER() OVER (
      PARTITION BY category      -- Number rows independently within each category
      ORDER BY     sales DESC  -- Number from 1 in descending sales order
    ) AS rn
  FROM products
)
SELECT *
FROM   ranked
WHERE  rn <= 2;   -- Keep only the top two rows in each category
Why a CTE is necessary:The result of a window function such as ROW_NUMBER is evaluated in the SELECT phase. Because WHERE is evaluated earlier, you cannot use a ROW_NUMBER result in WHERE within the same SELECT. A CTE is needed to expose the result to an outer query.
Problem

From the product_sales table below, extract the two products with the highest sales in each category.

Even when values tie, use ROW_NUMBER to force the result to exactly two products.

Tables
▸ product_sales
product_idcategoryproduct_namesales
P01FoodApple85000
P02FoodBanana62000
P03FoodOrange62000
P04FoodGrapes41000
P05DrinkGreen Tea95000
P06DrinkCoffee78000
P07DrinkJuice78000
P08DrinkWater55000
Expected Output
categoryproduct_namesalesrn
DrinkGreen Tea950001
DrinkCoffee780002
FoodApple850001
FoodBanana620002

※ Banana and Orange tie at 62000, but ROW_NUMBER makes only one of them second (the choice depends on the database’s internal order unless a tiebreaker such as product_id is added)

QUESTION 4
CTE + LAG — Calculate Month-over-Month Growth
LAGCTEMonth-over-MonthGrowth RateTime-Series API
Background

LAG(col, n) is a window function that returns the value n rows before the current row. It is essential for time-series comparisons such as month-over-month, year-over-year, and day-over-day changes.

LAG(amount, 1) OVER (ORDER BY order_month)
-- Return amount from the preceding row when ordered by order_month
-- The first row has no preceding row, so it returns NULL
LAG’s third argument (default value):LAG(col, 1, 0) returns 0 instead of NULL. Use it when an API response should avoid NULL.
The corresponding LEAD function references a following row (covered in Q9).

Month-over-month formula: (current month − previous month) ÷ previous month × 100. Use NULLIF to prevent division by zero when the previous month is NULL or 0.

Problem

Using the monthly_sales table below, create an API query that calculates each month’s sales, previous-month sales, and month-over-month growth rate (%).

For the first month, which has no preceding month, output NULL for both prev_sales and mom_rate.

Tables
▸ monthly_sales
order_monthsales
2024-01100000
2024-02120000
2024-03108000
2024-04135000
2024-05135000
2024-06150000
Expected Output
order_monthsalesprev_salesmom_rate
2024-01100000NULLNULL
2024-02120000100000+20.0
2024-03108000120000-10.0
2024-04135000108000+25.0
2024-051350001350000.0
2024-06150000135000+11.1
QUESTION 5
Recursive CTE — Expand Every Level of a Manager-to-Report Hierarchy
Recursive CTERECURSIVEHierarchy QueryOrganization APIWITH RECURSIVE
Background

A recursive CTE uses WITH RECURSIVE to process a hierarchy iteratively by referencing itself. Common uses include organization charts, category hierarchies, and bills of materials (BOMs).

WITH RECURSIVE org_tree AS (
  -- ① Anchor member: recursion starting point (select the top row)
  SELECT id, name, manager_id, 0 AS depth
  FROM   employees
  WHERE  manager_id IS NULL    -- No manager means the top level

  UNION ALL                    -- Vertically combine anchor and recursive rows, including duplicates

  -- ② Recursive member: keep adding direct reports to the preceding result
  SELECT e.id, e.name, e.manager_id, t.depth + 1
  FROM   employees e
  JOIN   org_tree   t ON e.manager_id = t.id
)
SELECT * FROM org_tree;
Recursion termination condition:Recursion ends when the recursive member returns zero rows. In production, add a depth limit such as WHERE depth < 10 to prevent infinite loops.
Problem

From the self-referencing employees table below, expand the hierarchy of every employee starting from the CEO, whose manager_id is NULL.

For each employee, also output depth (hierarchy depth, with CEO = 0) and path (for example, "CEO→Director→Manager").

Tables
▸ employees
emp_idnamemanager_id
1Alex (CEO)NULL
2Blake (Sales Director)1
3Casey (Engineering Director)1
4Drew (Sales Manager)2
5Emery (Sales Rep)4
6Finley (Tech Lead)3
Expected Output
emp_idnamemanager_iddepthpath
1Alex (CEO)NULL0Alex (CEO)
2Blake (Sales Director)11Alex (CEO)→Blake (Sales Director)
3Casey (Engineering Director)11Alex (CEO)→Casey (Engineering Director)
4Drew (Sales Manager)22Alex (CEO)→Blake (Sales Director)→Drew (Sales Manager)
6Finley (Tech Lead)32Alex (CEO)→Casey (Engineering Director)→Finley (Tech Lead)
5Emery (Sales Rep)43Alex (CEO)→Blake (Sales Director)→Drew (Sales Manager)→Emery (Sales Rep)