Logical deletion — track deleted rows with deleted_at and retrieve only active data
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 = TIMESTAMP '2024-06-01 06:30' WHERE user_id = 42; -- Retrieve only active records: rows whose deleted_at is NULL SELECT * FROM users WHERE deleted_at IS NULL;
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.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 = TIMESTAMP '2024-06-01 06:30').
② Retrieve only active users that have not been logically deleted, sort by newest last_login_at first, and use COALESCE to put NULLs last.
| user_id | name | last_login_at | deleted_at (timestamp) |
|---|---|---|---|
| U01 | Alice | 2024-05-20 | NULL |
| U02 | Bob | 2024-01-10 | NULL |
| U03 | Carol | NULL | NULL |
| U04 | Dave | 2024-02-28 | NULL |
| U05 | Eve | 2024-05-01 | 2024-04-01 |
After UPDATE ① (U02 and U04 are logical-deletion targets):
| user_id | name | last_login_at | deleted_at |
|---|---|---|---|
| U01 | Alice | 2024-05-20 | NULL |
| U02 | Bob | 2024-01-10 | 2024-06-01 06:30:00 |
| U03 | Carol | NULL | NULL |
| U04 | Dave | 2024-02-28 | 2024-06-01 06:30:00 |
| U05 | Eve | 2024-05-01 | 2024-04-01 00:00:00 |
SELECT result ② (U01 and U03 only; last_login descending, with NULL last):
| user_id | name | last_login_at |
|---|---|---|
| U01 | Alice | 2024-05-20 |
| U03 | Carol | NULL |
LAG-based change detection — Compare values with the previous batch run and extract only changed rows
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
Delta-batch pattern: process only changed rows instead of retrieving everything to reduce external API calls and avoid cost and rate-limit problems.
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.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.
| snapshot_id | product_id | snapshot_date | quantity |
|---|---|---|---|
| 1 | P01 | 2024-06-01 | 100 |
| 2 | P01 | 2024-06-02 | 85 |
| 3 | P01 | 2024-06-03 | 85 |
| 4 | P02 | 2024-06-01 | 200 |
| 5 | P02 | 2024-06-02 | 200 |
| 6 | P02 | 2024-06-03 | 215 |
| product_id | snapshot_date | quantity | prev_quantity | diff |
|---|---|---|---|---|
| P01 | 2024-06-02 | 85 | 100 | -15 |
| P02 | 2024-06-03 | 215 | 200 | +15 |
Cursor pagination — Fetch the next page while avoiding LIMIT/OFFSET problems
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
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).
| order_id | customer_id | amount | status |
|---|---|---|---|
| 1001 | C01 | 5000 | pending |
| 1002 | C02 | 3000 | completed |
| 1003 | C03 | 7000 | pending |
| 1004 | C04 | 2500 | pending |
| 1005 | C05 | 8000 | pending |
| 1006 | C06 | 4500 | pending |
| 1007 | C01 | 6000 | cancelled |
| 1008 | C02 | 9000 | pending |
| order_id | customer_id | amount | status | next_cursor |
|---|---|---|---|---|
| 1004 | C04 | 2500 | pending | 1008 |
| 1005 | C05 | 8000 | pending | 1008 |
| 1006 | C06 | 4500 | pending | 1008 |
| 1008 | C02 | 9000 | pending | 1008 |
Incremental update batches — Efficiently process only rows changed since the previous run using updated_at
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 < TIMESTAMP '2024-06-01 06:30'; -- Before the current time (exclude changes during this run)
indexing updated_at makes WHERE filtering fast. In production, it is important to include updated_at from the table-design stage.
batch_jobs. After the batch completes, overwrite it with “this run's start time” as the reference for the next run.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()).
| order_id | customer_id | amount | status | updated_at |
|---|---|---|---|---|
| 1 | C01 | 5000 | completed | 2024-06-01 01:00 |
| 2 | C02 | 3000 | pending | 2024-06-01 02:00 |
| 3 | C01 | 7000 | completed | 2024-06-01 03:00 |
| 4 | C03 | 2500 | cancelled | 2024-05-31 12:00 |
| 5 | C02 | 8000 | completed | 2024-06-01 04:00 |
| job_name | last_run_at |
|---|---|
| order_sync | 2024-06-01 00:00:00 |
| customer_id | order_count | total_amount | last_updated_at |
|---|---|---|---|
| C01 | 2 | 12000 | 2024-06-01 03:00 |
| C02 | 2 | 11000 | 2024-06-01 04:00 |
Batch job monitoring — Monitor duration, stalls, and consecutive failures in one SQL query
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
(ended_at - started_at) returns an INTERVAL, converting it to seconds calculates the execution time.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)
| run_id | job_name | status | started_at | ended_at |
|---|---|---|---|---|
| 1 | order_sync | success | 2024-06-01 02:00 | 2024-06-01 02:05 |
| 2 | order_sync | failed | 2024-06-01 03:00 | 2024-06-01 03:02 |
| 3 | email_batch | success | 2024-06-01 02:00 | 2024-06-01 02:30 |
| 4 | order_sync | failed | 2024-06-01 04:00 | 2024-06-01 04:01 |
| 5 | email_batch | failed | 2024-06-01 03:00 | 2024-06-01 03:05 |
| 6 | order_sync | running | 2024-06-01 05:00 | NULL |
| 7 | email_batch | failed | 2024-06-01 04:00 | 2024-06-01 04:08 |
| 8 | order_sync | failed | 2024-06-01 06:00 | 2024-06-01 06:03 |
| 9 | email_batch | failed | 2024-06-01 05:00 | 2024-06-01 05:06 |
| job_name | success_count | fail_count | avg_sec | is_stalled | alert |
|---|---|---|---|---|---|
| email_batch | 1 | 3 | 735.0 | false | ! ALERT |
| order_sync | 1 | 3 | 165.0 | true | ! ALERT |