CTE + CROSS JOIN + COALESCE — Build a Complete Matrix by Zero-Filling Missing Months
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)
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.
| sale_month | product_id | amount |
|---|---|---|
| 2024-01 | P01 | 30000 |
| 2024-01 | P02 | 45000 |
| 2024-02 | P01 | 50000 |
| 2024-03 | P02 | 20000 |
| 2024-03 | P03 | 35000 |
| product_id | product_name |
|---|---|
| P01 | Apple |
| P02 | Banana |
| P03 | Orange |
| sale_month | product_id | product_name | total_sales |
|---|---|---|---|
| 2024-01 | P01 | Apple | 30000 |
| 2024-01 | P02 | Banana | 45000 |
| 2024-01 | P03 | Orange | 0 |
| 2024-02 | P01 | Apple | 50000 |
| 2024-02 | P02 | Banana | 0 |
| 2024-02 | P03 | Orange | 0 |
| 2024-03 | P01 | Apple | 0 |
| 2024-03 | P02 | Banana | 20000 |
| 2024-03 | P03 | Orange | 35000 |
Decompose Logic with CTEs — Split Complex Filters into Readable Steps
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.
WITH filtered AS ( -- 1. Row-level filtering belongs in WHERE SELECT * FROM table_name WHERE date_col >= DATE '2024-01-01' ), grouped AS ( -- 2. Group-level filtering belongs in HAVING SELECT key_col, COUNT(*) AS cnt, SUM(num_col) AS total FROM filtered GROUP BY key_col HAVING COUNT(*) >= 2 ) SELECT * FROM grouped;
COUNT(*) >= 2, use HAVING. WHERE operates before grouping at the row level; HAVING operates after grouping at the aggregate level.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
| user_id | user_name |
|---|---|
| U01 | Alice |
| U02 | Bob |
| U03 | Carol |
| U04 | Dave |
| U05 | Eve |
| order_id | user_id | amount | order_date |
|---|---|---|---|
| 1 | U01 | 20000 | 2024-01-15 |
| 2 | U01 | 35000 | 2024-03-10 |
| 3 | U02 | 60000 | 2024-02-20 |
| 4 | U03 | 15000 | 2024-01-05 |
| 5 | U03 | 25000 | 2024-03-22 |
| 6 | U04 | 80000 | 2024-03-01 |
| 7 | U04 | 10000 | 2024-04-05 |
| 8 | U05 | 12000 | 2024-02-14 |
| user_id | user_name | order_count | total_amount | last_order_date |
|---|---|---|---|---|
| U01 | Alice | 2 | 55000 | 2024-03-10 |
| U04 | Dave | 2 | 90000 | 2024-04-05 |
CTE + SUM OVER — Calculate a Running Sales Total
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
UNBOUNDED means there is no limit before the edge of the partition.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.
| order_month | sales |
|---|---|
| 2024-01 | 95000 |
| 2024-02 | 120000 |
| 2024-03 | 108000 |
| 2024-04 | 145000 |
| 2024-05 | 132000 |
| 2024-06 | 160000 |
| order_month | monthly_sales | cumulative_sales | achievement_rate |
|---|---|---|---|
| 2024-01 | 95000 | 95000 | 11.9 |
| 2024-02 | 120000 | 215000 | 26.9 |
| 2024-03 | 108000 | 323000 | 40.4 |
| 2024-04 | 145000 | 468000 | 58.5 |
| 2024-05 | 132000 | 600000 | 75.0 |
| 2024-06 | 160000 | 760000 | 95.0 |
CTE + LEAD — Calculate the Next Purchase Date and Interval for Churn Prediction
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
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.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.
| log_id | user_id | order_date |
|---|---|---|
| 1 | U01 | 2024-01-10 |
| 2 | U01 | 2024-02-15 |
| 3 | U01 | 2024-04-01 |
| 4 | U02 | 2024-01-20 |
| 5 | U02 | 2024-03-05 |
| 6 | U03 | 2024-02-28 |
| user_id | order_date | next_date | days_to_next |
|---|---|---|---|
| U01 | 2024-01-10 | 2024-02-15 | 36 |
| U01 | 2024-02-15 | 2024-04-01 | 46 |
| U01 | 2024-04-01 | NULL | NULL |
| U02 | 2024-01-20 | 2024-03-05 | 45 |
| U02 | 2024-03-05 | NULL | NULL |
| U03 | 2024-02-28 | NULL | NULL |
Multiple CTEs + CASE WHEN — Segment Users by RFM Score
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
CURRENT_DATE. In production, accept the reference date as a parameter.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
| order_id | user_id | amount | order_date |
|---|---|---|---|
| 1 | U01 | 30000 | 2024-01-10 |
| 2 | U01 | 50000 | 2024-02-20 |
| 3 | U01 | 40000 | 2024-03-25 |
| 4 | U02 | 80000 | 2024-03-15 |
| 5 | U02 | 60000 | 2024-03-28 |
| 6 | U03 | 120000 | 2023-12-01 |
| 7 | U04 | 15000 | 2024-03-30 |
| 8 | U04 | 20000 | 2024-03-31 |
| 9 | U04 | 18000 | 2024-04-01 |
| user_id | recency_days | frequency | monetary | r_score | f_score | m_score | total_score | segment |
|---|---|---|---|---|---|---|---|---|
| U01 | 7 | 3 | 120000 | 3 | 3 | 3 | 9 | VIP |
| U02 | 4 | 2 | 140000 | 3 | 2 | 3 | 8 | VIP |
| U04 | 0 | 3 | 53000 | 3 | 3 | 2 | 8 | VIP |
| U03 | 122 | 1 | 120000 | 1 | 1 | 3 | 5 | Standard |