Skip to content
Good engineers know 4 min read · All formats 3 practice problems ↓

MERGE INTO and upserts

Matched, not-matched and not-matched-by-source clauses, step by step, and the duplicate-source error.

You will learn

  • How MERGE combines insert, update and delete in one atomic statement
  • Every clause, including WHEN NOT MATCHED BY SOURCE
  • How MERGE executes, and why it can be slow
  • The duplicate-source error and how to avoid it

Read first

Comfortable with these? Read on.

TL;DR MERGE INTO target USING source ON condition matches source rows to target rows and, per clause, updates, deletes or inserts, all in one commit. Each target row may match at most one source row, so deduplicate the source first.

What it does

MERGE is the standard way to apply changes (upserts, CDC feeds, corrections) to a lakehouse table. One statement, one atomic commit, no window in which readers see half the changes.

PySparkSpark SQL
from delta.tables import DeltaTable

(DeltaTable.forName(spark, "customers").alias("t")
    .merge(updates.alias("s"), "t.customer_id = s.customer_id")
    .whenMatchedDelete(condition="s.op = 'DELETE'")
    .whenMatchedUpdate(set={"email": "s.email", "tier": "s.tier", "updated_at": "s.ts"})
    .whenNotMatchedInsert(condition="s.op != 'DELETE'", values={
        "customer_id": "s.customer_id", "email": "s.email", "tier": "s.tier", "updated_at": "s.ts"})
    .whenNotMatchedBySourceDelete(condition="t.tier = 'trial'")
    .execute())
MERGE INTO customers t
USING updates s
ON t.customer_id = s.customer_id
WHEN MATCHED AND s.op = 'DELETE' THEN DELETE
WHEN MATCHED THEN UPDATE SET t.email = s.email, t.tier = s.tier, t.updated_at = s.ts
WHEN NOT MATCHED AND s.op != 'DELETE' THEN INSERT (customer_id, email, tier, updated_at)
  VALUES (s.customer_id, s.email, s.tier, s.ts)
WHEN NOT MATCHED BY SOURCE AND t.tier = 'trial' THEN DELETE

The clauses

ClauseApplies toCan
WHEN MATCHED [AND cond]Target rows with a matching source rowUPDATE or DELETE
WHEN NOT MATCHED [AND cond]Source rows with no target matchINSERT
WHEN NOT MATCHED BY SOURCE [AND cond]Target rows with no source matchUPDATE or DELETE (Delta 2.3+, Iceberg with recent Spark)
  • Clauses of the same kind are checked in order; the first whose condition holds is applied to a row.
  • UPDATE SET * and INSERT * copy all columns by name.
  • Without a NOT MATCHED BY SOURCE clause, target rows absent from the source are untouched. With an unconditional one, MERGE becomes a full sync and deletes everything not in the source: be careful.

How MERGE executes

Step 1 · find touched files

Spark joins the source with the target (inner join on the ON condition) to find which target files contain matching rows. With file statistics and partition predicates in the ON condition, many files are skipped here.

Step 2 · rewrite

For copy-on-write tables, each touched file is read in full, the updates and deletes are applied, and a new file is written with all its rows, changed or not. New inserts are written to new files. With deletion vectors, unchanged rows need not be rewritten.

Step 3 · commit

One commit removes the touched files and adds the rewritten and new ones. Readers see all of the merge or none of it.

Cost · what dominates

Rewriting whole files to change a few rows in each. Updating 10,000 rows spread over 10,000 files rewrites 10,000 files. Clustering the target by the merge key, and adding partition or date predicates to the ON clause, keeps the touched set small.

The duplicate source error

If two source rows match the same target row, MERGE cannot know which update to apply, so it fails (Delta reports that multiple source rows matched the same target row). The fix is to make the source unique per key before merging, usually keeping the latest change:

Spark SQL
WITH latest AS (
  SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ts DESC) AS rn
    FROM updates)
  WHERE rn = 1)
MERGE INTO customers t
USING latest s ON t.customer_id = s.customer_id
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *

Making MERGE faster

  • Put selective predicates in the ON condition, for example AND t.event_date >= '2025-03-01', so file skipping can prune the target.
  • Cluster the target by the merge key (Z-order or liquid clustering) so matches concentrate in few files.
  • Enable deletion vectors (Delta) or merge-on-read (Iceberg) for update-heavy tables.
  • Keep the source small: merge only changed rows, not a full snapshot.
  • Broadcast a small source; check the join strategy in the plan.

Common mistakes

Merging a source with duplicate keys

The MERGE fails. Deduplicate first.

An unconditional WHEN NOT MATCHED BY SOURCE DELETE with a partial source

Deletes every target row missing from the batch.

No pruning predicate on huge targets

The whole target is scanned and many files rewritten.

Key takeaways

  • MERGE applies inserts, updates and deletes in one atomic commit.
  • WHEN NOT MATCHED BY SOURCE handles target rows missing from the source.
  • Each target row may match only one source row: deduplicate the source.
  • Cost is driven by how many files are rewritten; prune and cluster.

Check yourself

3 questions

1. Two source rows match the same target row. What happens?

Show the answer

The MERGE fails. The update would be ambiguous, so the MERGE raises an error.

2. Which clause deletes target rows that are not in the source?

Show the answer

WHEN NOT MATCHED BY SOURCE THEN DELETE. NOT MATCHED BY SOURCE targets rows absent from the source.

3. What usually dominates the cost of a copy-on-write MERGE?

Show the answer

Rewriting whole files that contain matched rows. Each touched file is rewritten in full.

Practice it

Interview problems that use this: write the PySpark, run it, and get graded on hidden tests.

Solve: Apply a CDC Batch (MERGE Semantics) →

Go deeper

Primary sources: Delta: upsert with MERGE · Iceberg: Spark writes (MERGE INTO)