CTEs and the WITH Clause — Learn Multiple CTEs, Recursive CTEs, Running Totals, LEAD, and RFM in Practice
AdvancedCTEMultiple & Recursive CTEsRunning Totals / LEADRFMAPI Use CasesPostgreSQL/BigQuery5 questions
QUESTION 6
CTE + CROSS JOIN + COALESCE — Build a Complete Matrix by Zero-Filling Missing Months
CTECROSS JOINCOALESCELEFT JOINZero-Fill API
Background

Real-world data omits month-product combinations with no sales. Dashboard APIs often need a complete matrix of every month × every product, with zero for no sales, which you can build with CROSS JOIN + LEFT JOIN + COALESCE.

-- CROSS JOIN: generate every combination of two tables (Cartesian product)
SELECT m.month, p.product_id
FROM   months   m
CROSS JOIN products p
-- 3 rows in months × 4 rows in products = all 12 combinations

-- COALESCE: return the first non-NULL value
COALESCE(sales, 0)   -- Return 0 when sales is NULL (replace no sales with zero)
Why LEFT JOIN is necessary: CROSS JOIN creates every combination. LEFT JOINing the actual-results table then leaves NULL in the sales column for combinations with no sales. COALESCE converts those NULL values to zero and completes the matrix.
Problem

Using the sales and products tables below, create a sales matrix for every month from 2024-01 through 2024-03 × every product.

Fill combinations with no sales with 0. Generate the month list in a CTE.

Tables
▸ sales
sale_monthproduct_idamount
2024-01P0130000
2024-01P0245000
2024-02P0150000
2024-03P0220000
2024-03P0335000
▸ products
product_idproduct_name
P01Apple
P02Banana
P03Orange
Expected Output
sale_monthproduct_idproduct_nametotal_sales
2024-01P01Apple30000
2024-01P02Banana45000
2024-01P03Orange0
2024-02P01Apple50000
2024-02P02Banana0
2024-02P03Orange0
2024-03P01Apple0
2024-03P02Banana20000
2024-03P03Orange35000
QUESTION 7
Decompose Logic with CTEs — Split Complex Filters into Readable Steps
Multiple CTEsHAVINGAggregate FilteringSegment APICTE
Background

Production APIs often need compound conditions such as “made at least two purchases, spent at least 50,000 in total, and made the latest purchase on or after a specified date.” Packing all of this into one query makes maintenance difficult. Decomposing the logic into CTE steps lets you test and modify each condition independently.

HAVING: This clause filters groups after GROUP BY. When a condition uses an aggregate such as COUNT(*) >= 2, use HAVING. WHERE operates before grouping at the row level; HAVING operates after grouping at the aggregate level.
Problem

Using the orders and users tables below, build an API query with CTEs that extracts users who meet all three conditions, decomposing the logic into steps.

Condition ①: at least 2 purchases in total
Condition ②: at least 50,000 in total purchases
Condition ③: latest purchase date on or after 2024-03-01

Tables
▸ users
user_iduser_name
U01Alice
U02Bob
U03Carol
U04Dave
U05Eve
▸ orders
order_iduser_idamountorder_date
1U01200002024-01-15
2U01350002024-03-10
3U02600002024-02-20
4U03150002024-01-05
5U03250002024-03-22
6U04800002024-03-01
7U04100002024-04-05
8U05120002024-02-14
Expected Output
user_iduser_nameorder_counttotal_amountlast_order_date
U01Alice2550002024-03-10
U04Dave2900002024-04-05

Note: Bob fails condition ① with only 1 purchase / Carol fails condition ② with a total of 40000 / Eve fails with 1 purchase and condition ③

QUESTION 8
CTE + SUM OVER — Calculate a Running Sales Total
SUM OVERCTERunning TotalROWS BETWEENProgress API
Background

SUM() OVER (ORDER BY ...) is a window function that calculates the total through the current row (running total). Beyond monthly sales trends, it powers progress APIs that report how far you have advanced toward an annual target.

SUM(sales) OVER (
  ORDER BY order_month              -- Sort by month, then calculate the running total
  ROWS BETWEEN UNBOUNDED PRECEDING  -- ROWS BETWEEN: specify the range of rows to aggregate
    AND CURRENT ROW                  -- From the first row through the current row = running total
) AS cumulative_sales
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: This frame specification means “from the first row through the current row.” A running total may also be computed with ORDER BY alone, but writing the frame explicitly makes the intent clear. UNBOUNDED means there is no limit before the edge of the partition.
Problem

Using the monthly_sales table below, build an API query that calculates monthly sales, cumulative sales, and the achievement rate (%) against an annual target of 800,000.

Aggregate by month in a CTE, then calculate the running total and achievement rate in the outer SELECT.

Tables
▸ monthly_sales
order_monthsales
2024-0195000
2024-02120000
2024-03108000
2024-04145000
2024-05132000
2024-06160000
Expected Output
order_monthmonthly_salescumulative_salesachievement_rate
2024-01950009500011.9
2024-0212000021500026.9
2024-0310800032300040.4
2024-0414500046800058.5
2024-0513200060000075.0
2024-0616000076000095.0
QUESTION 9
CTE + LEAD — Calculate the Next Purchase Date and Interval for Churn Prediction
LEADCTEPurchase IntervalChurn AnalysisLTV API
Background

LEAD(col, n) is the inverse of LAG and returns the value n rows after the current row. Use it to calculate when the next purchase occurs and how many days elapse until it.

LEAD(order_date, 1) OVER (
  PARTITION BY user_id    -- Reference the next row independently for each user
  ORDER BY     order_date  -- Return order_date from the next row in date order
)
-- The last purchase row has no next row and becomes NULL
Calculating date differences: In PostgreSQL, date2 - date1 returns the number of days as an INTEGER. BigQuery uses DATE_DIFF(date2, date1, DAY). A date difference containing NULL also returns NULL through NULL propagation.
Problem

Using the purchase_log table below, calculate the next purchase date and purchase interval in days for every purchase by every user.

For a user's last purchase, which has no next purchase, return NULL for both next_date and days_to_next.

Tables
▸ purchase_log
log_iduser_idorder_date
1U012024-01-10
2U012024-02-15
3U012024-04-01
4U022024-01-20
5U022024-03-05
6U032024-02-28
Expected Output
user_idorder_datenext_datedays_to_next
U012024-01-102024-02-1536
U012024-02-152024-04-0146
U012024-04-01NULLNULL
U022024-01-202024-03-0545
U022024-03-05NULLNULL
U032024-02-28NULLNULL
QUESTION 10
Multiple CTEs + CASE WHEN — Segment Users by RFM Score
Multiple CTEsCASE WHENRFM AnalysisDATEDIFFSegment API
Background

RFM analysis is a marketing method that evaluates customers on three axes—Recency (days since the last purchase), Frequency (number of purchases), and Monetary (purchase amount)—and assigns them to segments. Splitting the process across multiple CTEs isolates the calculation logic for each score.

-- CASE WHEN: transform a value with conditional branches (like if / else if)
CASE
  WHEN recency_days <  30 THEN 3   -- 3 points if under 30 days
  WHEN recency_days <  90 THEN 2   -- 2 points if under 90 days
  ELSE                       1   -- Otherwise, 1 point
END
Setting the reference date: Recency is the number of days from the latest purchase through today, but queries often fix the reference date. Historical test data would produce changing results with CURRENT_DATE. In production, accept the reference date as a parameter.
Problem

Using the orders table below, build an API query with multiple CTEs that calculates RFM scores of 1–3 points each and classifies users into VIP, Standard, or Dormant segments.

Use 2024-04-01 as the reference date. Scoring criteria:
R (Recency): <30 days=3, <90 days=2, otherwise=1
F (Frequency): ≥3 purchases=3, ≥2 purchases=2, otherwise=1
M (Monetary): ≥100000=3, ≥50000=2, otherwise=1
Segment: R+F+M ≥8=VIP, ≥5=Standard, otherwise=Dormant

Tables
▸ orders
order_iduser_idamountorder_date
1U01300002024-01-10
2U01500002024-02-20
3U01400002024-03-25
4U02800002024-03-15
5U02600002024-03-28
6U031200002023-12-01
7U04150002024-03-30
8U04200002024-03-31
9U04180002024-04-01
Expected Output
user_idrecencyfreqmonetaryRFMtotalsegment
U040 days3 purchases530003328VIP
U024 days2 purchases1400003238VIP
U017 days3 purchases1200003339VIP
U03122 days1 purchase1200001135Standard

Note: R is calculated from the elapsed days as of the reference date 2024-04-01. U03 has recency 122 days (not <90 days, so 1 point). total_score≥8=VIP, ≥5=Standard, otherwise=Dormant.