ReviseAlgo Logo

Data Modification

UPSERT and MERGE

Handling write collisions by updating existing database values.

Last Updated: June 15, 2026 16 min read

1. Introduction

UPSERT (UPDATE or INSERT) is a pattern that inserts a new row if it doesn't exist, or updates the existing row if a conflict occurs on a unique constraint. PostgreSQL implements this with INSERT ... ON CONFLICT. The SQL standard defines MERGE for more complex conditional insert/update/delete logic, which PostgreSQL 15+ supports natively.

2. Why It Matters

  • Idempotent writes: UPSERT makes operations safe to retry — critical for message queues, webhook handlers, and distributed systems where duplicate delivery is common.
  • Counter aggregation: Increment counters (page views, vote counts) atomically without race conditions using ON CONFLICT DO UPDATE SET count = count + 1.
  • ETL pipelines: Data warehouse loads use MERGE to handle both new records and corrections to existing records in a single pass.
  • Configuration management: Store application settings that should be created on first use and updated on subsequent uses.

3. Real-World Analogy

Imagine a hotel front desk. A guest arrives: if they have a reservation (conflict), you update their room assignment. If they don't (no conflict), you create a new reservation. UPSERT is this exact logic — check for an existing record, and either update or insert accordingly — all in one atomic operation.

4. How It Works

PostgreSQL ON CONFLICT (UPSERT):

-- Insert or update on unique constraint conflict
INSERT INTO user_stats (user_id, login_count, last_login)
VALUES (42, 1, NOW())
ON CONFLICT (user_id)
DO UPDATE SET
    login_count = user_stats.login_count + 1,
    last_login = EXCLUDED.last_login;

-- Insert or do nothing (skip duplicates) INSERT INTO emails (address, user_id) VALUES ('alice@example.com', 42) ON CONFLICT (address) DO NOTHING;

-- Using a partial unique index as conflict target INSERT INTO featured_products (product_id, category, is_featured) VALUES (101, 'Electronics', true) ON CONFLICT (product_id) WHERE category = 'Electronics' DO UPDATE SET is_featured = EXCLUDED.is_featured;

PostgreSQL 15+ MERGE statement:

-- MERGE: Conditional INSERT, UPDATE, and DELETE in one statement
MERGE INTO inventory AS target
USING new_shipment AS source
ON target.product_id = source.product_id
WHEN MATCHED AND target.quantity + source.quantity > 1000 THEN
    UPDATE SET quantity = 1000, overflow_note = 'Capped at max'
WHEN MATCHED THEN
    UPDATE SET quantity = target.quantity + source.quantity,
               last_restocked = NOW()
WHEN NOT MATCHED THEN
    INSERT (product_id, quantity, last_restocked)
    VALUES (source.product_id, source.quantity, NOW());

5. Internal Architecture

UPSERT Execution Flow:
┌─────────────────────────────────────────────────────────┐
│ 1. Attempt INSERT into table                             │
│ 2. Check unique constraint / unique index                │
│    ├─ No conflict → INSERT succeeds                      │
│    └─ Conflict detected →                                │
│       3. Lock the conflicting row                        │
│       4. Evaluate DO UPDATE SET expressions              │
│          - EXCLUDED.* references the proposed new row    │
│          - table.* references the existing row           │
│       5. Apply UPDATE to existing row                    │
│       6. Re-check constraints on updated row             │
│ 7. Write WAL record → Return                             │
└─────────────────────────────────────────────────────────┘

Key: The entire UPSERT is atomic — no race condition between the check and the write.

6. Visual Explanation

7. Practical Example

Atomic counter with UPSERT:

-- Page view counter (handles concurrent requests safely)
INSERT INTO page_views (page_url, view_date, view_count)
VALUES ('/products/123', CURRENT_DATE, 1)
ON CONFLICT (page_url, view_date)
DO UPDATE SET view_count = page_views.view_count + 1;

-- Shopping cart: add item or increase quantity INSERT INTO cart_items (user_id, product_id, quantity, added_at) VALUES (42, 101, 2, NOW()) ON CONFLICT (user_id, product_id) DO UPDATE SET quantity = cart_items.quantity + EXCLUDED.quantity, added_at = NOW();

Data synchronization with MERGE:

-- Sync staging data to production, handling inserts, updates,
-- and soft-deletes for removed records
MERGE INTO customers AS prod
USING staging_customers AS stg
ON prod.email = stg.email
WHEN MATCHED AND stg.is_active = false THEN
    UPDATE SET deleted_at = NOW()
WHEN MATCHED THEN
    UPDATE SET
        name = stg.name,
        phone = stg.phone,
        updated_at = NOW()
WHEN NOT MATCHED THEN
    INSERT (email, name, phone, created_at)
    VALUES (stg.email, stg.name, stg.phone, NOW());

8. Common Mistakes

Missing unique index for conflict target

ON CONFLICT (col) requires a unique index or constraint on col. Without it, PostgreSQL raises an error: "there is no unique or exclusion constraint matching the ON CONFLICT specification."

Using EXCLUDED incorrectly

EXCLUDED refers to the row that was proposed for INSERT. In DO UPDATE SET col = EXCLUDED.col, the left side is the existing row's column, and EXCLUDED is the new value. Mixing these up leads to incorrect data.

Race conditions with SELECT + INSERT pattern

Never use SELECT ... if not exists INSERT else UPDATE — this has a race condition. Use ON CONFLICT instead, which is atomic.

Interview Insight

"How do you handle idempotent writes in a database?" — Use UPSERT (ON CONFLICT DO UPDATE) with a unique constraint on the idempotency key. This ensures duplicate requests produce the same result as a single request.

9. Quick Quiz

Q1: What does ON CONFLICT DO NOTHING return?

Answer: It skips the conflicting row silently. No error is raised, and no update occurs. The command returns successfully with a row count indicating how many rows were actually inserted (0 if all conflicted).

Q2: What is the EXCLUDED pseudo-table?

Answer: EXCLUDED is a special table reference in ON CONFLICT clauses that represents the row that was proposed for INSERT. It contains all the values from the VALUES clause or the SELECT query.

10. Scenario-Based Challenge

Challenge: Real-Time Leaderboard

Build a game leaderboard that handles concurrent score updates from thousands of players:

  1. Create a leaderboard(player_id, game_id, high_score, games_played) table with a composite unique constraint on (player_id, game_id).
  2. Write an UPSERT that updates high_score only if the new score is higher, and always increments games_played.
  3. Handle the case where a player's score should only be updated if it beats their previous record.
  4. Add a "last_active" timestamp that updates on every UPSERT.

11. Debugging Exercise

Find the bug in this UPSERT:

INSERT INTO settings (user_id, key, value)
VALUES (42, 'theme', 'dark')
ON CONFLICT (user_id)
DO UPDATE SET value = 'dark';

Issues:

  1. Wrong conflict target: The unique constraint should be on (user_id, key), not just (user_id). A user has multiple settings keys.
  2. Hardcoded value: Use EXCLUDED.value instead of the hardcoded string to make the query reusable for any setting value.

12. Interview Questions

Q1: How do you implement idempotent API writes with SQL?

A: Use an idempotency key (unique per API request) as a unique constraint. On retry, UPSERT with ON CONFLICT returns the existing result instead of creating a duplicate. Pattern: INSERT ... ON CONFLICT (idempotency_key) DO NOTHING.

Q2: What's the difference between ON CONFLICT and MERGE?

A: ON CONFLICT handles a single table's insert/update based on one unique constraint. MERGE can handle complex multi-way logic: different UPDATE actions based on different conditions, DELETE when matched, and INSERT when not matched — all in one statement. MERGE is more expressive but more complex.

Q3: Can UPSERT trigger unique constraint violations on other columns?

A: Yes. If the DO UPDATE SET clause modifies a column that has a separate unique constraint, the UPDATE itself can cause a conflict. PostgreSQL checks all constraints on the updated row, not just the original conflict target.

13. Production Considerations

  • Index overhead: Every unique constraint requires a unique B-tree index. High-write tables with many UPSERTs should minimize redundant unique indexes.
  • Lock contention: UPSERT acquires a row-level lock on the conflicting row. High-concurrency UPSERTs on the same key create a serialization bottleneck — consider application-level deduplication first.
  • WAL generation: Each UPSERT that results in an UPDATE generates WAL records just like a regular UPDATE. Heavy UPSERT workloads should tune WAL settings accordingly.
  • Partial unique indexes: Use CREATE UNIQUE INDEX ... WHERE condition for conflict targets that only apply to a subset of rows (e.g., only active records).
  • Monitoring: Track UPSERT hit rates (insert vs update ratio) to understand data flow patterns. A 90% update rate may indicate an opportunity to batch or cache at the application layer.