Batch Processing — Learn UPSERT, bulk updates, archiving, and deduplication in Practice
AdvancedBatch processingUPSERT and bulk updatesArchivingDeduplicationWebApp APIPostgreSQL5 questions
QUESTION 1
UPSERT — Combine inserts and updates in one query with INSERT ... ON CONFLICT DO UPDATE
INSERTON CONFLICTUPSERTAPI batch synchronization
Background

UPSERT (Upsert = Update + Insert) is a pattern that performs “UPDATE if the record exists, INSERT otherwise” in one query. It is one of the most common patterns for synchronizing data from an external service in an API batch.

INSERT INTO target_table (col1, col2, ...)
VALUES (...)
ON CONFLICT (unique_col)          -- Specify the unique-key column used for conflict detection
DO UPDATE SET
  col2 = EXCLUDED.col2;          -- EXCLUDED = the value being inserted (the conflicting new data)
What is EXCLUDED?
It is a special table reference available inside ON CONFLICT DO UPDATE. It refers to the values of the row that was going to be inserted but conflicted. Use EXCLUDED.col for the new value and target_table.col for the existing value.

DO NOTHING means “skip the row when it conflicts.” It is useful when you need idempotency: running the same data repeatedly produces the same result.

Problem

Synchronize a product master from an external EC service in a daily batch. In the products table, INSERT new products and UPDATE name/price/updated_at for existing products.

Synchronization data to write directly in VALUES: (external ID: EXT-001, name: Wireless Mouse Rev. 2, price: 3200), (EXT-002, Mechanical Keyboard, 8900), (EXT-003, 4-Port USB Hub, 1980)

Tables used
▸ products (before sync)
idexternal_idnamepriceupdated_at
1EXT-001Wireless Mouse28002024-01-10
2EXT-002Mechanical Keyboard89002024-01-10
Expected Output

The products table after the query runs:

idexternal_idnamepriceupdated_at
1EXT-001Wireless Mouse Rev. 232002024-06-01
2EXT-002Mechanical Keyboard89002024-06-01
3EXT-0034-Port USB Hub19802024-06-01

※ EXT-001 updates its name, price, and updated_at; EXT-002 keeps its name and price but gets a new updated_at; EXT-003 is inserted as a new row.

QUESTION 2
UPDATE FROM — Bulk-update multiple rows by reading another table
UPDATEFROMJOIN UPDATEBatch price revision
Background

UPDATE ... FROM is a pattern for bulk-updating a target table while reading values from another table. It is central to batch processing, for example updating a product table from a price-revision master or changing statuses by matching an approved list.

UPDATE target                        -- Table to update
SET    col = src.col                -- Set a value from the source table
FROM   source src                   -- Source table (joined like a JOIN)
WHERE  target.key = src.key;       -- Join condition (without it, every row is updated!)
Forgetting the WHERE join condition updates every row: If you write FROM but omit its join condition in WHERE, every row in the target table is updated. Always add a WHERE condition on the join key.

PostgreSQL uses UPDATE ... FROM; MySQL uses UPDATE target JOIN source ON ....

Problem

Use the price_updates (price revision master) table to bulk-update price and updated_at in products. Only products whose product_id exists in price_updates are targets.

Tables used
▸ products (before update)
product_idnamepriceupdated_at
101Coffee Bean A12002024-01-01
102Coffee Bean B15002024-01-01
103Black Tea Leaf C9002024-01-01
104Green Tea D8002024-01-01
▸ price_updates (price revision master)
product_idnew_priceapplied_at
10113802024-06-01
10310502024-06-01
Expected Output

products after the update (only 101 and 103 found in price_updates change):

product_idnamepriceupdated_at
101Coffee Bean A13802024-06-01
102Coffee Bean B15002024-01-01
103Black Tea Leaf C10502024-06-01
104Green Tea D8002024-01-01
QUESTION 3
INSERT INTO ... SELECT — Bulk-transfer and archive qualifying rows in another table
INSERT INTOSELECTArchivingBatch transfer
Background

INSERT INTO ... SELECT inserts the result of a SELECT directly into another table. It is widely used in batch processing to move old data into an archive table or write aggregate results to a summary table.

INSERT INTO archive_table (col1, col2, ...)
SELECT       col1, col2, ...          -- SELECT column order and types must match the target
FROM         source_table
WHERE        condition;                -- Condition that narrows transfer targets
VALUES is unnecessary: INSERT INTO ... SELECT does not use a VALUES clause. The SELECT result itself becomes the rows to insert.

To delete rows from the source after archiving, pair DELETE + INSERT INTO...SELECT in one transaction. This prevents data from disappearing during the transfer.

Problem

Bulk-copy completed orders from before 2024-02-01 (order_date < '2024-02-01') from orders into orders_archive. Set the archived_at column to the current time after copying.

Tables used
▸ orders
order_idcustomer_idamountstatusorder_date
1001C0112000completed2024-01-15
1002C028500cancelled2024-01-20
1003C0315000completed2024-01-28
1004C019200completed2024-02-05
1005C046700completed2024-02-10
▸ orders_archive (empty target)
order_idcustomer_idamountstatusorder_datearchived_at
(empty)
Expected Output

After execution, orders_archive receives two rows: 1001 and 1003.

order_idcustomer_idamountstatusorder_datearchived_at
1001C0112000completed2024-01-152024-06-01 02:00:00
1003C0315000completed2024-01-282024-06-01 02:00:00

※ 1002 (cancelled), 1004, and 1005 (February or later) are excluded.

QUESTION 4
ROW_NUMBER deduplication — Extract only the latest record per key with a CTE
ROW_NUMBERCTEDeduplicationData cleansing
Background

Duplicate keys can appear in a table after a batch import or a double submission. Combining ROW_NUMBER() with a CTE lets you write a cleansing query that keeps only the latest record within each duplicate-key group.

WITH ranked AS (
  SELECT *,
    ROW_NUMBER() OVER (
      PARTITION BY dup_key           -- Column used to identify duplicates
      ORDER BY     created_at DESC  -- Number rows newest first (1 is newest)
    ) AS rn
  FROM target_table
)
SELECT * FROM ranked WHERE rn = 1;  -- Return only rank 1 (latest) from each group
What is a CTE? (Common Table Expression)
WITH name AS (SELECT ...) defines a temporary named result set. The main query can reference it by name. It is more readable than nesting subqueries and is a standard way to write batch processing queries.
Problem

The user_events table contains duplicate data because the same user_id and event_type combination was imported multiple times. Write a query that keeps only the latest record for each user_id + event_type.

Table used
▸ user_events (with duplicates)
event_iduser_idevent_typepayloadcreated_at
1U01loginip:1.2.3.42024-06-01 09:00
2U01loginip:5.6.7.82024-06-01 12:00
3U01purchaseitem:A2024-06-01 10:00
4U02loginip:9.9.9.92024-06-01 11:00
5U01purchaseitem:B2024-06-01 15:00
Expected Output

After deduplication (the latest row for each user_id + event_type):

event_iduser_idevent_typepayloadcreated_atrn
2U01loginip:5.6.7.82024-06-01 12:001
5U01purchaseitem:B2024-06-01 15:001
4U02loginip:9.9.9.92024-06-01 11:001
QUESTION 5
CASE WHEN bulk status update — Set different values by condition in one UPDATE
UPDATECASE WHENStatus transitionsBatch state management
Background

When a batch needs to UPDATE different values for several conditions, CASE WHEN in the SET clause combines the work into one query.

UPDATE orders
SET status =
  CASE
    WHEN condition A THEN 'value_a'   -- Rows matching condition A become value_a
    WHEN condition B THEN 'value_b'   -- Rows matching condition B become value_b
    ELSE              status       -- Rows matching neither condition keep their value
  END
WHERE filter condition;            -- Limit the UPDATE targets (important)
Why ELSE matters: If ELSE is omitted, rows matching no condition become NULL. When “leave unchanged” is intended, always write ELSE column_name (preserve the current value) or ELSE 'an appropriate default'.
Problem

In a nightly batch, bulk-update statuses in the jobs table using these rules.

  • retry_count = 0 and status = 'failed' → 'pending' (requeue)
  • retry_count ≥ 3 and status = 'failed' → 'abandoned' (give up)
  • started_at is NULL and status = 'running' → 'stalled' (detect a zombie)
  • Leave all other rows unchanged
Table used
▸ jobs (before update)
job_idstatusretry_countstarted_at
J01failed02024-06-01
J02failed32024-06-01
J03running0NULL
J04completed02024-06-01
J05failed12024-06-01
Expected Output

The jobs table after the update:

job_idstatus (after update)Change
J01pendingfailed(0) → pending
J02abandonedfailed(3) → abandoned
J03stalledrunning(NULL) → stalled
J04completedNo change
J05failedNo change (retry_count=1)