MERGE INTO - the Delta Upsert Engine

Estimated read: 17 min

If you’ve written an Oracle MERGE or a SQL Server MERGE/hand-rolled upsert (update-if-exists, insert-if-not) in a legacy ETL job, Delta Lake’s MERGE INTO will feel immediately familiar – same problem, same general shape, running against a Lakehouse table instead. This is one of the highest-leverage statements in this entire guide; almost every silver-layer pipeline in Part 2 uses it.

Basic upsert

MERGE INTO main.default.orders AS target
USING staging_orders AS source
ON target.order_id = source.order_id
WHEN MATCHED THEN
  UPDATE SET *
WHEN NOT MATCHED THEN
  INSERT *;

This is the pattern: match rows between a target table and an incoming source (a staging table, a streaming micro-batch, a CDC feed) on a key, update matched rows, insert unmatched ones. UPDATE SET * and INSERT * update/insert every column by name-matching – explicit column lists are also supported and often clearer in production code.

The full clause set

MERGE INTO main.default.orders AS target
USING staging_orders AS source
ON target.order_id = source.order_id
WHEN MATCHED AND source.is_deleted = true THEN
  DELETE
WHEN MATCHED AND source.updated_at > target.updated_at THEN
  UPDATE SET
    order_total  = source.order_total,
    order_status = source.order_status,
    updated_at   = source.updated_at
WHEN NOT MATCHED THEN
  INSERT (order_id, customer_id, order_total, order_status, updated_at)
  VALUES (source.order_id, source.customer_id, source.order_total, source.order_status, source.updated_at)
WHEN NOT MATCHED BY SOURCE THEN
  UPDATE SET target.order_status = 'archived';
Clause Meaning
WHEN MATCHED ... THEN UPDATE Row exists in both – update it (optionally conditioned)
WHEN MATCHED ... THEN DELETE Row exists in both, but source signals deletion – remove it
WHEN NOT MATCHED THEN INSERT Row exists only in source – it’s new, insert it
WHEN NOT MATCHED BY SOURCE Row exists only in target – source no longer has it

Key term: WHEN NOT MATCHED BY SOURCE is the clause most people coming from a warehouse MERGE haven’t used as often – it handles rows present in the target but absent from the source, useful for soft-deleting or archiving records a source system has since removed, rather than only ever adding/updating.

The single-match constraint

A MERGE requires that at most one row in the source match a given target row (or vice versa for insert-side matching) – if your source has duplicate keys, MERGE fails with an error rather than silently picking one arbitrarily or applying both. This is a deliberate correctness guarantee, not a bug – deduplicate the source before the MERGE, not by hoping the engine picks reasonably.

-- Deduplicate source before merging, keeping the latest by updated_at
CREATE OR REPLACE TEMP VIEW deduped_source AS
SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC) AS rn
  FROM staging_orders
) WHERE rn = 1;

SCD Type 2 with MERGE INTO

A common pattern this guide revisits directly in Section 9 (Lakeflow Declarative Pipelines) and throughout Part 2: maintaining a slowly changing dimension Type 2 history (every change to a row creates a new version with started_at/ended_at timestamps, rather than overwriting in place) using MERGE:

MERGE INTO customers_scd2 AS target
USING (
  SELECT *, current_timestamp() AS effective_ts FROM staging_customers
) AS source
ON target.customer_id = source.customer_id AND target.is_current = true
WHEN MATCHED AND target.email != source.email THEN
  UPDATE SET target.is_current = false, target.ended_at = source.effective_ts
WHEN NOT MATCHED THEN
  INSERT (customer_id, email, is_current, started_at, ended_at)
  VALUES (source.customer_id, source.email, true, source.effective_ts, NULL);

This single statement only closes out changed rows – a second MERGE (or the AUTO CDC flow API covered in Section 9, which automates this exact pattern) inserts the new current version.

Comparing to your legacy MERGE

Legacy warehouse MERGE Delta MERGE INTO
Runs against a single-engine, ACID-native table Runs against Parquet + transaction log, same ACID guarantees
Often locks the target table during the operation Optimistic concurrency – conflicting concurrent writes retry or fail cleanly, not blocking readers
Source is usually another table in the same engine Source can be a table, a view, a streaming micro-batch, or a DataFrame

The mental model transfers almost entirely – the syntax differences are smaller than the underlying execution model differences, and those differences (no table locking, works against streaming micro-batches) are generally advantages once you’re used to them.

For the full official syntax reference, including Python/Scala DeltaTable API equivalents beyond the SQL shown here, see Delta Lake MERGE INTO.


| ← Previous: RESTORE and Rollback Strategies | Next: DELETE, UPDATE and Idempotent writes → | |:—|—:|


Back to top

Independent, self-authored Databricks companion notes.

This site uses Just the Docs, a documentation theme for Jekyll.