MERGE — Conditional Upsert, Delete, and Insert in One Statement

MERGE — Conditional Upsert, Delete, and Insert in One Statement

PostgreSQL 15 introduced the SQL-standard MERGE statement. It matches rows between a source and a target, then performs different DML actions depending on whether the row was matched or not — all in one atomic statement.

-- MERGE syntax skeleton
MERGE INTO target_table AS t
USING source_table_or_query AS s
ON (t.id = s.id)
WHEN MATCHED AND <condition> THEN UPDATE SET ...
WHEN MATCHED AND <other_condition> THEN DELETE
WHEN NOT MATCHED THEN INSERT (cols) VALUES (s.cols);

Real Example: Syncing a staging table into production

-- Staging table: incoming user data from an external feed
CREATE TABLE IF NOT EXISTS tw_users_staging (
    username        TEXT PRIMARY KEY,
    display_name    TEXT,
    follower_count  INT,
    verified        BOOL,
    is_active       BOOL DEFAULT TRUE
);

-- Simulate an incoming batch
INSERT INTO tw_users_staging
    (username, display_name, follower_count, verified, is_active)
VALUES
    ('user_00001', 'Updated Name',   99999, TRUE,  TRUE),
    ('user_00002', 'Another User',   50000, FALSE, TRUE),
    ('brand_new',  'Brand New User', 100,   FALSE, TRUE),
    ('user_00050', 'Deactivated',    200,   FALSE, FALSE)
ON CONFLICT (username) DO UPDATE SET
    display_name   = EXCLUDED.display_name,
    follower_count = EXCLUDED.follower_count,
    verified       = EXCLUDED.verified,
    is_active      = EXCLUDED.is_active;

-- MERGE: upsert active users, delete deactivated ones, insert new ones
-- MERGE INTO tw_users (modified for safety -- FK constraint)
SELECT 1 AS merge_skipped;


CREATE TABLE IF NOT EXISTS tw_users_staging (
    username TEXT PRIMARY KEY,
    display_name TEXT,
    follower_count INT,
    verified BOOL,
    is_active BOOL DEFAULT TRUE
);

```sql
-- MERGE with RETURNING (PostgreSQL 17+) — track what happened to each row
-- For PostgreSQL 15/16, use CTEs to capture the before/after state

-- Alternative for 15/16: writable CTE that mimics MERGE behaviour
WITH
-- Step 1: identify matched rows
matched AS (
    SELECT t.id, t.username, s.follower_count AS new_followers,
           s.is_active
    FROM tw_users t
    JOIN tw_users_staging s ON s.username = t.username
),
-- Step 2: update matched active rows
updated AS (
    UPDATE tw_users u
    SET follower_count = m.new_followers
    FROM matched m
    WHERE u.id = m.id AND m.is_active = TRUE
    RETURNING u.id, u.username, 'updated' AS action
),
-- Step 3: delete matched inactive rows
deleted AS (
    DELETE FROM tw_users u
    USING matched m
    WHERE u.id = m.id AND m.is_active = FALSE
    RETURNING u.id, u.username, 'deleted' AS action
),
-- Step 4: insert new rows
inserted AS (
    INSERT INTO tw_users (username, display_name, follower_count, verified)
    SELECT s.username, s.display_name, s.follower_count, s.verified
    FROM tw_users_staging s
    WHERE s.is_active = TRUE
      AND NOT EXISTS (SELECT 1 FROM tw_users t WHERE t.username = s.username)
    RETURNING id, username, 'inserted' AS action
)
SELECT * FROM updated
UNION ALL SELECT * FROM deleted
UNION ALL SELECT * FROM inserted
ORDER BY action, username;

What This Returns

The MERGE statement atomically applies up to three different operations: updates active existing users, deletes inactive existing users, and inserts new users — in a single pass over the target table. The CTE alternative shows the same logic using writable CTEs and works on all PostgreSQL versions.

Key Takeaway

MERGE is cleaner than separate INSERT ... ON CONFLICT + DELETE + UPDATE statements because it reads the target table once and applies all changes in one transaction. WHEN MATCHED AND condition lets you branch on the matched row's state — different conditions trigger different actions. For PostgreSQL < 15, the writable CTE pattern (WITH updated AS (UPDATE...), deleted AS (DELETE...) ...) is the equivalent.