Batch Processing — Learn Correlated subqueries, CTEs, LAG/LEAD, and recursive CTEs in Practice
AdvancedBatch processingCorrelated subqueries / CTEsConditional aggregationLAG / LEADRecursive CTEPostgreSQL5 questions
QUESTION 1
Correlated subquery — retrieve only each user’s “latest order”
Correlated subqueryMAXLatest recordsOrder API
Background

A correlated subquery references a column from the outer query and is evaluated for each outer row, making it useful for finding a maximum or minimum under the same condition as the current row.

SELECT *
FROM   orders o1
WHERE  o1.ordered_at = (
  SELECT MAX(o2.ordered_at)       -- The inner query references the outer o1.user_id → correlated subquery
  FROM   orders o2
  WHERE  o2.user_id = o1.user_id  -- ← This is the core of the correlation: evaluated for each outer row
);
Key point:A correlated subquery can cost the number of outer rows × subquery executions, so a ROW_NUMBER() window-function version may be faster for large data sets. It remains a readable choice for small data sets and straightforward requirements.

Retrieving the latest row in a group—such as each user’s latest login or each product’s last price change—is one of the most common API and batch-processing patterns.

Problem

From the orders table, retrieve the row for each user (user_id) with the maximum ordered_at. If a user has multiple orders, return only the latest row. Sort the result by user_id ascending.

Tables used
▸ orders
order_iduser_idamountordered_at
1001U0132002024-03-10
1002U0115002024-05-20
1003U0280002024-04-01
1004U0221002024-06-15
1005U035002024-02-28
1006U0147002024-06-01
Expected Output

One latest order per user (user_id ascending):

order_iduser_idamountordered_at
1006U0147002024-06-01
1004U0221002024-06-15
1005U035002024-02-28
QUESTION 2
WITH clause (CTE) — Build a monthly sales report step by step
WITH / CTESUM / AVGSales reportsBatch aggregation
Background

A WITH clause (CTE, or Common Table Expression) defines a temporary named result set inside a query. Instead of nesting complex subqueries, you can separate the work into step-by-step blocks, greatly improving readability and maintainability.

WITH
  cte_name1 AS (              -- Block 1: define the first temporary table
    SELECT ... FROM some_table
  ),
  cte_name2 AS (              -- Block 2: cte_name1 is available here
    SELECT ... FROM cte_name1
  )
SELECT * FROM cte_name2;       -- The final SELECT produces the actual output
When to use it:1) when you want to flatten deeply nested subqueries, 2) when you want to reference the same subquery multiple times (MATERIALIZEDthe MATERIALIZED option can force one computation), and 3) when writing recursive queries (see Q5).
Problem

From the sales table, retrieve sales totals by month and product category, plus each category’s percentage of the monthly total. Use CTEs to build it in three stages: “monthly category aggregation” → “monthly total” → “ratio calculation”. Sort by month and category ascending.

Tables used
▸ sales
sale_idcategoryamountsold_at
1food30002024-05-03
2drink15002024-05-10
3food20002024-05-20
4drink25002024-06-05
5food40002024-06-12
6drink10002024-06-20
Expected Output

Monthly/category sales and percentage of the monthly total:

monthcategorycat_totalmonth_totalratio_pct
2024-05drink1500650023.08
2024-05food5000650076.92
2024-06drink3500750046.67
2024-06food4000750053.33
QUESTION 3
CASE WHEN + Conditional aggregation — inventory-status pivot aggregation with FILTER
CASE WHENSUM / FILTERPivot aggregationInventory API
Background

CASE WHEN is SQL’s conditional expression. Embedding it in an aggregate function enables conditional aggregation (pivot aggregation), where only rows meeting a condition are aggregated. PostgreSQL also supports the dedicated FILTER (WHERE ...) syntax.

-- Conditional aggregation with CASE WHEN (standard SQL)
SUM(CASE WHEN status = 'active' THEN amount ELSE 0 END) AS active_sum

-- Conditional aggregation with FILTER (PostgreSQL)
SUM(amount) FILTER (WHERE status = 'active') AS active_sum
What is pivot aggregation:It expands row-oriented data into columns so that aggregates for multiple categories appear on one row. Think of an Excel pivot table. The key is to place multiple category totals side by side on one row rather than GROUP BY into separate rows.
Problem

From the inventory table, retrieve the count of in-stock items (quantity > 0), out-of-stock items (quantity = 0), and all items on one row per warehouse as a pivot aggregation.

Tables used
▸ inventory
item_idwarehousequantity
A01Tokyo50
A02Tokyo0
A03Tokyo20
B01Osaka0
B02Osaka0
B03Osaka15
C01Fukuoka100
C02Fukuoka30
Expected Output

Inventory status summary by warehouse:

warehousein_stockout_of_stocktotal_items
Tokyo213
Osaka123
Fukuoka202
QUESTION 4
LAG / LEAD — Visualize trends with month-over-month and row-over-row changes
LAG / LEADOVER PARTITION BYMonth-over-month changeSales analysis
Background

LAG(column, n) is a window function that returns the value n rows before the current row. LEAD(column, n) returns the value n rows after it. Its key feature is adding the difference from a previous row to the same row without aggregating rows with GROUP BY.

LAG(col, 1, 0) OVER (            -- Value one row before col; default 0 when there is no previous row
  PARTITION BY group_col         -- Keep an independent order within each group
  ORDER BY     sort_col          -- Define “previous” and “next” by this order (required)
)

LEAD(col, 1) OVER (             -- Value one row after col (the next row)
  ORDER BY sort_col
)
ORDER BY is required: When using LAG/LEAD in a window function, ORDER BY inside the OVER clause Without a defined order, “the previous row” is undefined.
Problem

monthly_sales table, retrieve monthly sales, previous-month sales, and month-over-month change (%) for each product (product_id). When there is no previous-month data (the first month), previous-month sales and month-over-month change may be NULL. Sort by product_id and month ascending.

Tables used
▸ monthly_sales
product_idmonthrevenue
P012024-0310000
P012024-0412000
P012024-059000
P022024-035000
P022024-047500
P022024-057500
Expected Output

Monthly sales with month-over-month change (product_id and month ascending):

product_idmonthrevenueprev_revenuemom_pct
P012024-0310000NULLNULL
P012024-041200010000+20.00
P012024-05900012000-25.00
P022024-035000NULLNULL
P022024-0475005000+50.00
P022024-05750075000.00
QUESTION 5
Recursive CTE (WITH RECURSIVE) — retrieve all descendants in a category hierarchy
WITH RECURSIVEUNION ALLHierarchical dataCategory API
Background

WITH RECURSIVE is syntax for writing a recursive query in which a CTE references itself. It is used to traverse hierarchical data such as category trees, organization charts, and bills of materials.

WITH RECURSIVE cte AS (
  -- ① Anchor member (the recursion starting point; runs once at the beginning)
  SELECT id, parent_id, name, 0 AS depth
  FROM   categories
  WHERE  id = 1                  -- Select one starting row

  UNION ALL                      -- Connect the anchor and recursive members (fast because duplicates are not removed)

  -- ② Recursive member (cte references the previous iteration; repeat until it returns zero rows)
  SELECT c.id, c.parent_id, c.name, r.depth + 1
  FROM   categories c
  JOIN   cte         r ON c.parent_id = r.id  -- Retrieve rows whose parent ID is the previous iteration’s id
)
SELECT * FROM cte;
Infinite-loop warning:If the data contains a circular reference (A→B→A), recursion never stops. Always add a limit such as depth < 10 or loop detection using a path column.
Problem

categories is a self-referencing hierarchy through parent_id. Retrieve all descendant categories starting from category_id = 1, including grandchildren and great-grandchildren, not just direct children with their depth (hierarchy level). Treat the starting point as depth 0.

Tables used
▸ categories
category_idparent_idname
1NULLFood
21Drinks
31Snacks
42Coffee
52Juice
63Chocolate
710Miscellaneous (separate tree)
Expected Output

All descendants under category_id=1 (Food):

category_idparent_idnamedepth
1NULLFood0
21Drinks1
31Snacks1
42Coffee2
52Juice2
63Chocolate2