Batch Processing — Learn Logical deletion, pagination, incremental updates, and monitoring in Practice
AdvancedBatch processingLogical deletionPaginationIncremental updates and monitoringWebApp APIPostgreSQL5 questions
QUESTION 6
Logical deletion — track deleted rows with deleted_at and retrieve only active data
UPDATEIS NULLLogical deletionsoft delete
Background

Logical deletion (soft delete) is a pattern that treats a row as deleted by setting a value in the deleted_at timestamp column instead of physically DELETEing it. Use it when an API or batch job needs to restore the pre-deletion state or retain deletion history.

-- Logical deletion: set the current time in deleted_at
UPDATE users
SET    deleted_at = NOW()
WHERE  user_id = 42;

-- Retrieve only active records: rows whose deleted_at is NULL
SELECT * FROM users
WHERE  deleted_at IS NULL;
COALESCE(value, default): returns the first non-NULL value. COALESCE(deleted_at, '9999-12-31') treats a NULL deleted_at as a date far in the future. Use it for date sorting instead of a logical-deletion flag.
Problem

Implement each of the following requirements as a separate query.

① In the users table, logically delete users whose last_login_at is at least 90 days old (deleted_at = NOW()).

② Retrieve only active users that have not been logically deleted, sort by newest last_login_at first, and use COALESCE to put NULLs last.

Tables used
▸ users (reference date: 2024-06-01)
user_idnamelast_login_atdeleted_at
U01Alice2024-05-20NULL
U02Bob2024-01-10NULL
U03CarolNULLNULL
U04Dave2024-02-28NULL
U05Eve2024-05-012024-04-01
Expected Output

After UPDATE ① (U02 and U04 are logical-deletion targets):

user_idnamelast_login_atdeleted_at (after update)
U01Alice2024-05-20NULL (active)
U02Bob2024-01-102024-06-01 02:00:00
U03CarolNULLNULL (active)
U04Dave2024-02-282024-06-01 02:00:00
U05Eve2024-05-012024-04-01 (already deleted)

SELECT result ② (U01 and U03 only; last_login descending, with NULL last):

user_idnamelast_login_at
U01Alice2024-05-20
U03CarolNULL (last)
QUESTION 7
LAG-based change detection — Compare values with the previous batch run and extract only changed rows
LAGChange detectionDelta batchWindow function
Background

LAG(column, N) returns the value N rows earlier within the same partition. It is ideal for a delta batch that compares the previous batch value with the current value and extracts only changed rows.

LAG(column, 1) OVER (
  PARTITION BY group_key        -- Compare preceding and following values within each group
  ORDER BY     time_col         -- Return the value from the previous row after sorting chronologically
)                                -- The first row is NULL because there is no previous row
Difference from LEAD:LAG returns the previous row's value, while LEAD returns the next row's value. Use LAG for change detection because it compares the previous and current values.

Delta-batch pattern: process only changed rows instead of retrieving everything to reduce external API calls and avoid cost and rate-limit problems.

Problem

The stock_snapshots table records daily inventory snapshots. Extract only products whose quantity changed from the previous day and output the change amount (diff) as well.

Tables used
▸ stock_snapshots
snapshot_idproduct_idsnapshot_datequantity
1P012024-06-01100
2P012024-06-0285
3P012024-06-0385
4P022024-06-01200
5P022024-06-02200
6P022024-06-03215
Expected Output

Rows with a change from the previous day:

product_idsnapshot_datequantityprev_quantitydiff
P012024-06-0285100-15
P022024-06-03215200+15
QUESTION 8
Cursor pagination — Fetch the next page while avoiding LIMIT/OFFSET problems
WHERELIMITPaginationAPI response
Background

When an API returns a large dataset, LIMIT/OFFSET pagination becomes slower as OFFSET grows because more rows must be scanned. Cursor-based pagination passes the last retrieved record's ID (the cursor) in the next request and can consistently use an index.

-- OFFSET method (problematic): the page around row 1,000,000 scans all skipped rows
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 990000; -- Slow

-- Cursor method (recommended): use LIMIT to take rows greater than the previous last id
SELECT * FROM orders
WHERE  id > :last_seen_id           -- Cursor: the last ID retrieved previously
ORDER BY id
LIMIT  10;                          -- Fast because it always uses the index
When cursor pagination is a good fit: Infinite scrolling that fetches the next page in sequence and chunking batch processing. OFFSET is useful when users must jump to an arbitrary page number, such as in an admin console.
Problem

Write a query that retrieves the next page from orders, five rows per page using a cursor (the last order_id).

Conditions: status = 'pending', order_id ascending, and the previous last order_id is 1003 (parameter :last_id). Also output the cursor for the next page (the last order_id).

Tables used
▸ orders
order_idcustomer_idamountstatus
1001C015000pending
1002C023000completed
1003C037000pending
1004C042500pending
1005C058000pending
1006C064500pending
1007C016000cancelled
1008C029000pending
Expected Output

Next page, up to 5 rows (pending rows greater than 1003, ordered by order_id):

order_idcustomer_idamountstatusnext_cursor
1004C042500pending1008
1005C058000pending1008
1006C064500pending1008
1008C029000pending1008 (next cursor)

Note: There is no next page because only 4 rows remain (detectable when row count < LIMIT).

QUESTION 9
Incremental update batches — Efficiently process only rows changed since the previous run using updated_at
WHEREIncremental updatesIncremental batchupdated_at
Background

Instead of retrieving and processing every row, filter with updated_at >= the previous batch run time and process only changed records. This incremental (delta) batch pattern is common in production for API data synchronization, aggregate-table updates, and notifications.

-- Pass the previous batch run time as :last_run_at
SELECT *
FROM   orders
WHERE  updated_at >= :last_run_at   -- Only rows changed since the previous run
  AND  updated_at <  NOW();        -- Before the current time (exclude changes during this run)
Managing batch run times: Store the previous run time (last_run_at) in a control table such as batch_jobs. After the batch completes, overwrite it with “this run's start time” as the reference for the next run.

indexing updated_at makes WHERE filtering fast. In production, it is important to include updated_at from the table-design stage.

Problem

Using the orders and batch_checkpoints tables, implement the following.

① Retrieve the previous batch run time from batch_checkpoints.

② Extract orders updated since the previous run and aggregate order count, total amount, and latest update time by customer_id.

③ Update batch_checkpoints with this batch’s start time (NOW()).

Tables used
▸ orders
order_idcustomer_idamountstatusupdated_at
1C015000completed2024-06-01 01:00
2C023000pending2024-06-01 02:00
3C017000completed2024-06-01 03:00
4C032500cancelled2024-05-31 12:00
5C028000completed2024-06-01 04:00
▸ batch_checkpoints
job_namelast_run_at
order_sync2024-06-01 00:00:00
Expected Output

② Aggregated result (only orders whose updated_at changed after 6/1 00:00; order_id 4 is excluded):

customer_idorder_counttotal_amountlast_updated_at
C012120002024-06-01 03:00
C022110002024-06-01 04:00
QUESTION 10
Batch job monitoring — Monitor duration, stalls, and consecutive failures in one SQL query
EXTRACTCOUNTJob monitoringBatch operations
Background

This pattern monitors execution time, stalls, and consecutive failures in a batch-job history table with one query. It is useful for API health-check endpoints and monitoring dashboards.

-- Calculate duration: convert the timestamp difference to seconds
EXTRACT(EPOCH FROM (ended_at - started_at)) AS duration_sec
-- EXTRACT(EPOCH FROM interval): convert an interval to seconds

-- Consecutive-failure count: failures among the latest N runs
COUNT(*) FILTER (WHERE status = 'failed')  -- Count only matching rows
EXTRACT(EPOCH FROM interval): Converts the difference between two timestamps (an INTERVAL) to a number of seconds. Because (ended_at - started_at) returns an INTERVAL, converting it to seconds calculates the execution time.
Problem

Using the job_runs table, output the following summary for each job in one query.

  • successes, failures, and average execution time (seconds) among the latest 5 runs
  • jobs currently running for at least 30 minutes (stall detection)
  • job names with at least 3 failures in the latest 5 runs (alert targets)
Tables used
▸ job_runs
run_idjob_namestatusstarted_atended_at
1order_syncsuccess06-01 02:0006-01 02:05
2order_syncfailed06-01 03:0006-01 03:02
3email_batchsuccess06-01 02:0006-01 02:30
4order_syncfailed06-01 04:0006-01 04:01
5email_batchfailed06-01 03:0006-01 03:05
6order_syncrunning06-01 05:00NULL
7email_batchfailed06-01 04:0006-01 04:08
8order_syncfailed06-01 06:0006-01 06:03
9email_batchfailed06-01 05:0006-01 05:06
Expected Output

Job monitoring summary (reference time: 2024-06-01 06:30):

job_namesuccess_countfail_countavg_secis_stalledalert
email_batch13370.0false! ALERT
order_sync13165.0true !! ALERT