Batch Processing — Learn LATERAL, JSONB, differential updates, EXPLAIN, and ROLLUP in Practice
AdvancedBatch processingLATERAL / JSONBDifferential update batchesEXPLAINROLLUPPostgreSQL5 questions
QUESTION 6
LATERAL JOIN — Retrieve the top 3 products by revenue for each category
LATERAL JOINLIMITTop-N retrievalRanking API
Background

LATERAL allows the subquery on the right side of a JOIN to reference columns from rows on the left side. It is the JOIN version of a correlated subquery: “for each left-side row, run a subquery whose condition uses that row's values.”

SELECT *
FROM   categories c
JOIN LATERAL (                       -- The right-side subquery can reference c.category_id from the left
  SELECT * FROM products p
  WHERE  p.category_id = c.category_id  -- ← Reference c from the left (the core of LATERAL)
  ORDER BY p.revenue DESC
  LIMIT  3                            -- Keep the top 3 per category
) sub ON TRUE;                        -- ON TRUE: the LATERAL subquery already handles the join condition
“Top N per group” is especially intuitive with LATERAL: ROW_NUMBER() can also solve this, but LATERAL makes the intent “top 3 per category” explicit in the code.
Problem

Using the categories and products tables, retrieve the 3 products with the highest revenue in each category, including the category name. Include every product for categories with fewer than 3 products (LEFT JOIN LATERAL). Sort the result by category ID and descending revenue.

Tables used
▸ categories
category_idname
1Drinks
2Food
▸ products
product_idcategory_idnamerevenue
P011Coffee50000
P021Tea30000
P031Green tea45000
P041Water20000
P052Bread25000
P062Rice ball18000
Expected Output

Top 3 products in each category (LEFT JOIN LATERAL):

category_idcategory_nameproduct_idproduct_namerevenuerank
1DrinksP01Coffee500001
1DrinksP03Green tea450002
1DrinksP02Tea300003
2FoodP05Bread250001
2FoodP06Rice ball180002
QUESTION 7
JSONB operations — Search, aggregate, and update a PostgreSQL JSONB column
JSONB->> / @>JSON searchSettings API
Background

PostgreSQL's JSONB type stores JSON data in binary form and enables fast searches with indexes. It is useful when flexible data structures such as user settings, tags, and metadata need to be handled inside an RDB.

-- Extract JSON values (the text result can be cast)
col ->>  'key'            -- Extract as text (commonly used)
col ->   'key'            -- Keep the JSONB type (continue into nested references)
col ->>  0               -- Extract JSON array index 0 as text

-- Containment check (fast when combined with a GIN index)
col @>  '{"key": "value"}'::jsonb   -- Check whether col contains the specified key/value

-- Partial update of a JSONB column
jsonb_set(col, '{key}', '"new_value"'::jsonb)  -- Replace the value at the specified path
text vs JSONB:When a JSON string is stored as TEXT, SQL cannot search its contents. JSONB lets you filter by keys and values. A GIN index makes the @> operator fast.
Problem

For the settings JSONB column in user_settings, write (1) a query that extracts users whose notifications are enabled (notifications = true), and (2) an aggregate showing how many users use each theme (dark/light). In addition, (3) update the language for user_id=1 from 'en' to 'ja'.

Tables used
▸ user_settings
user_idsettings (JSONB)
1{"theme":"dark","language":"en","notifications":true}
2{"theme":"light","language":"ja","notifications":false}
3{"theme":"dark","language":"ja","notifications":true}
4{"theme":"light","language":"en","notifications":true}
Expected Output

(1) Users with notifications enabled (notifications=true):

user_idnotifications
1true
3true
4true

(2) User count by theme:

themeuser_count
dark2
light2

(3) After UPDATE (user_id=1):

user_idsettings (after update)
1{"theme":"dark","language":"ja","notifications":true}
QUESTION 8
Differential update batch — Classify changes with subqueries and run INSERT / UPDATE / DELETE
DML chainNOT EXISTSDifferential updateData synchronization batch
Background

In a data-synchronization batch with an external system, updating only the differences is safer and faster than deleting every row and inserting everything again. This exercise uses subqueries such as NOT EXISTS to classify rows to add, update, or delete, then executes the changes in one transaction.

-- Present in new data but absent from current data → INSERT target
WHERE NOT EXISTS (
  SELECT 1 FROM current data c WHERE c.id = n.id
)

-- Present in current data but absent from new data → DELETE target
WHERE NOT EXISTS (
  SELECT 1 FROM new data n WHERE n.id = c.id
)
NOT IN vs NOT EXISTS:If a subquery result contains even one NULL, NOT IN can evaluate to FALSE or UNKNOWN for the whole predicate and produce unexpected results. In practice, NOT EXISTS is the safer choice.
Problem

Compare products_current (the current database data) with products_new (the latest data fetched from an external system), then write a differential update batch that (1) INSERTs new products, (2) DELETEs products that are no longer needed, and (3) UPDATEs products whose values changed.

Tables used
▸ products_current (current DB)
product_idnameprice
P01Coffee300
P02Tea250
P03Green tea200
▸ products_new (external source)
product_idnameprice
P01Coffee350
P02Tea250
P04Roasted tea220
Expected Output

Final products_current table:

product_idnamepriceChange
P01Coffee350UPDATE (price 300→350)
P02Tea250No change
P04Roasted tea220INSERT (new product)

P03 (Green tea) does not exist in products_new, so it is DELETED.

QUESTION 9
EXPLAIN ANALYZE — Understand slow queries and improve them with indexes
EXPLAIN ANALYZEINDEXPerformanceTuning
Background

EXPLAIN ANALYZE outputs a query's execution plan together with its actual execution time and row counts. It is a basic tool for identifying why a query is slow, such as an unused index or a scan of too many rows.

EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 'U01';

-- Typical output:
-- Seq Scan on orders (cost=0.00..12.50 rows=1 width=50)
--              ↑ Full-table scan (index not used)
-- Index Scan using idx_orders_user_id on orders
--              ↑ Index used (fast)
Output keywordMeaningAction
Seq ScanScan every row (index not used)Create an index on the search column
Index ScanLocate rows using an indexGood state
Hash JoinJoin using a hash (suitable for large tables)An index on the join key can sometimes change it to a Nested Loop
Estimated vs actual rowsAccuracy of statisticsRun ANALYZE to update table statistics when the values differ substantially
EXPLAIN ANALYZE actually executes the query. When using EXPLAIN ANALYZE for UPDATE or DELETE, run it inside a transaction and then ROLLBACK.
Problem

Here are slow queries. Use EXPLAIN to identify the problems and create appropriate indexes. Also show a case where a composite index is effective.

Slow queries (large data set assumed)
▸ Problem queries
QueryProblem
SELECT * FROM orders WHERE user_id = 'U01'No INDEX on user_id → Seq Scan
SELECT * FROM orders WHERE user_id='U01' AND status='paid' ORDER BY ordered_at DESCMultiple conditions → composite index not used
SELECT * FROM products WHERE LOWER(name) = 'coffee'Function applied → index not used
Expected Output

Actions for each problem (create an index and observe the EXPLAIN result change):

QueryBeforeAfter
WHERE user_id = 'U01'Seq Scan (all rows)Index Scan (fast)
WHERE user_id AND status ORDER BY ordered_atSeq Scan or inefficient Index ScanIndex Scan (composite index fully used)
WHERE LOWER(name) = 'coffee'Seq Scan (function disables the index)Index Scan (functional index)
QUESTION 10
ROLLUP / CUBE — Generate a multidimensional aggregate report in one query
ROLLUP / CUBEGROUPING SETSMultidimensional aggregationReporting batch
Background

ROLLUP is a GROUP BY extension that automatically adds hierarchical subtotals and a grand total. CUBE generates aggregates for every combination. GROUPING SETS is the generalized form that lets you specify the combinations flexibly. These features consolidate multiple GROUP BY queries into one batch-report query.

GROUP BY ROLLUP(col1, col2)
-- Automatically generate col1+col2 detail, a col1 subtotal, and a grand total

GROUP BY CUBE(col1, col2)
-- Generate all 4 patterns: (col1,col2), (col1), (col2), ()

GROUP BY GROUPING SETS((col1, col2), (col1), ())
-- Enumerate any desired aggregate patterns (the generalization of ROLLUP/CUBE)
GROUPING() function:GROUPING(col1) returns 1 when the column is aggregated in a subtotal or total row. Use it to replace the NULL in a subtotal row with a label such as “Total.”
Problem

Using ROLLUP, retrieve total sales by year, month, and category from the sales table. Include yearly totals and a grand total (subtotal rows), and use the GROUPING() function to replace NULL with readable labels. Also show an all-combinations aggregation using CUBE on the same table.

Tables used
▸ sales
sale_idyearmonthcategoryamount
120241food3000
220241drink2000
320242food4000
420242drink1500
5202312food5000
6202312drink3000
Expected Output

ROLLUP (hierarchical aggregation: year → month → category):

year_labelmonth_labelcategory_labeltotal_amount
202312drink3000
202312food5000
2023December total8000
2023 total8000
20241drink2000
20241food3000
2024January total5000
20242drink1500
20242food4000
2024February total5500
2024 total10500
Grand total18500