Batch Processing — Learn CTEs, Window Functions, Running Totals, and Pivots in Practice
AdvancedBatch ProcessingCTEWindow FunctionsRunning Totals and PivotsAPI PracticePostgreSQL/BigQuery Compatible5 questions
QUESTION 1
CTE + GROUP BY — Aggregate a Daily Sales Summary for an API
WITHCTEGROUP BYDaily Batch
Background

A CTE (Common Table Expression) is a temporary named query defined as WITH name AS (...). In batch processing, a standard pattern is to split the three stages—aggregation, filtering, and formatting—into CTEs.

WITH aggregate_name AS (
  -- ① Define the aggregation (GROUP BY) here
  SELECT col1, SUM(col2) AS total
  FROM   table_name
  GROUP BY col1         -- Aggregate rows for each col1 value
)
-- ② Filter and sort the aggregated result
SELECT *
FROM   aggregate_name
WHERE  total > 10000  -- A CTE lets WHERE reference the aggregated total
ORDER BY col1;          -- Sort using the default ASC order
Why use a CTE:You normally cannot write WHERE SUM(...) and must use HAVING. Moving the aggregation into a CTE lets you treat its result as an ordinary column. As batch SQL grows, naming and separating processing stages becomes increasingly valuable.
Problem

From the orders table below, calculate the total sales, order count, and average order amount for each order_date.

Return only dates whose total sales are at least 50,000 yen, sorted by total sales in descending order.

Source table
▸ orders
order_idorder_dateamount
12024-04-0120000
22024-04-0135000
32024-04-0260000
42024-04-0215000
52024-04-0380000
62024-04-0340000
72024-04-0412000
82024-04-0418000
Expected Output
order_datetotal_salesorder_countavg_order
2024-04-03120000260000
2024-04-0275000237500
2024-04-0155000227500

04-04 is excluded because its total is 30,000 yen.

QUESTION 2
LAG() Window Function — Calculate Prior-Month Differences and Growth Rates
LAGWindow FunctionsMonth-over-MonthBatch Report
Background

LAG() is a window function that brings a value from one row earlier (or n rows earlier) onto the current row. It is essential for month-over-month and day-over-day calculations.

LAG(value_column, 1, 0) OVER (
  PARTITION BY group_column   -- Reference the previous row independently within each group
  ORDER BY     sort_column       -- This order determines which row is previous
) AS previous_value

The arguments are LAG(column, offset, default). The offset defaults to 1 (one row earlier), and the default value is used when no previous row exists, as on the first row.

OVER and PARTITION BY:OVER() lets a window function calculate values without collapsing rows. If PARTITION BY is omitted, all rows form one group.
Problem

From the monthly_sales table below, calculate each month’s previous-month sales, difference from the previous month, and month-over-month growth rate (%).

Source table
▸ monthly_sales
monthsales
2024-01100000
2024-02130000
2024-03120000
2024-04160000
2024-05145000
Expected Output
monthsalesprev_salesdiffgrowth_rate
2024-011000000NULLNULL
2024-02130000100000+3000030.00
2024-03120000130000-10000-7.69
2024-04160000120000+4000033.33
2024-05145000160000-15000-9.38
QUESTION 3
SUM() OVER (ROWS BETWEEN) — Calculate a Running Sales Total and Moving Average
SUM OVERROWS BETWEENRunning TotalMoving Average
Background

The ROWS BETWEEN clause of a window function precisely specifies which range of rows to aggregate for each current row.

SUM(sales) OVER (
  ORDER BY month
  ROWS BETWEEN UNBOUNDED PRECEDING    -- From the first row
           AND CURRENT ROW            -- Through the current row → running total
) AS running_total

AVG(sales) OVER (
  ORDER BY month
  ROWS BETWEEN 2 PRECEDING           -- From two rows earlier
           AND CURRENT ROW            -- Through the current row → three-month moving average
) AS moving_avg_3m
Batch-processing use case:This pattern is common in time-series batches, such as identifying the month when cumulative sales exceeded a target. A window function is essential because it aggregates without removing rows.
Problem

From the monthly_sales table below, calculate each month’s running sales total (running_total) and moving average for the latest three months (moving_avg_3m).

Source table
▸ monthly_sales
monthsales
2024-0180000
2024-02120000
2024-03100000
2024-04150000
2024-05130000
2024-06170000
Expected Output
monthsalesrunning_totalmoving_avg_3m
2024-01800008000080000.0
2024-02120000200000100000.0
2024-03100000300000100000.0
2024-04150000450000123333.3
2024-05130000580000126666.7
2024-06170000750000150000.0
QUESTION 4
ROW_NUMBER() + CTE — Build an API for Top-N Products by Category
ROW_NUMBERPARTITION BYTop NRanking API
Background

APIs often need only the top N rows in each category. A standard pattern assigns each row a rank within its group using ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...), isolates that result in a CTE, and then filters with WHERE rn <= N.

WITH ranked AS (
  SELECT *,
    ROW_NUMBER() OVER (
      PARTITION BY category        -- Number rows independently within each category
      ORDER BY     sales DESC, product_id  -- Break ties by product_id ascending
    ) AS rn
  FROM products
)
SELECT *
FROM   ranked
WHERE  rn <= 2;   -- Keep only the top two rows in each category
ROW_NUMBER vs. RANK vs. DENSE_RANK:ROW_NUMBER always assigns a unique sequence even when values tie. RANK gives ties the same rank and skips the next number (1, 1, 3), while DENSE_RANK does not skip it (1, 1, 2). ROW_NUMBER is usually easiest for extracting exactly N rows.
Problem

From the product_sales table below, extract the top two products by sales within each category.

Source table
▸ product_sales
product_idcategoryproduct_namesales
P01FoodApple85000
P02FoodBanana62000
P03FoodMandarin62000
P04FoodGrape41000
P05RefreshmentsGreen tea95000
P06RefreshmentsCoffee78000
P07RefreshmentsJuice78000
P08RefreshmentsWater55000
Expected Output
categoryproduct_namesalesrn
FoodApple850001
FoodBanana620002
RefreshmentsGreen tea950001
RefreshmentsCoffee780002

Ties are resolved deterministically by product_id in ascending order.

QUESTION 5
CASE WHEN + SUM — Pivot Counts by Status
CASE WHENPivotSUM FILTERCross Tabulation
Background

Pivot aggregation, which expands row-oriented data into columns, is common in dashboard APIs. Because many SQL dialects lack a dedicated PIVOT statement, it can be implemented with CASE WHEN + SUM.

SELECT
  category,
  SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS completed,
  SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending
FROM   tasks
GROUP BY category;
PostgreSQL also supports FILTER:COUNT(*) FILTER (WHERE status = 'done') produces the same result. CASE WHEN is more portable because some databases, including BigQuery, do not support this FILTER syntax.
Problem

From the tasks table below, return one row per assignee containing counts by status (done, in progress, and not started) plus the total count.

Source table
▸ tasks
task_idassigneestatus
T01Bakerdone
T02Bakerdone
T03Bakerin progress
T04Bakernot started
T05Clarkdone
T06Clarkin progress
T07Clarkin progress
T08Adamsnot started
T09Adamsnot started
T10Adamsdone
Expected Output
assigneedonein_progressnot_startedtotal
Adams1023
Baker2114
Clark1203