Lakehouse Digest
ISSUE 013 · OCTOBER 1, 2026

Rank, don't dedupe: duplicate-safe CDC MERGE patterns

A CDC window is a timeline, not a row set. Rank every key's events, keep the deterministic latest change, then dispatch delete, update, and insert operations without ambiguous matches or resurrected tombstones.

THE THREE-PART READ

Sequence the window. Merge by intent. Prove the result.

A reproducible SQL pattern for ordered CDC windows with multiple events per business key and delete tombstones in the feed.

TIP · AMBIGUITY

Events per key are the duplicates

An insert plus an update for one key creates two source rows that can match one target row.

Understand the failure →
IMPLEMENTATION · REPRODUCIBLE

Rank, then MERGE

Keep one deterministic latest event per key and route tombstones away from the insert branch.

Run the SQL →
DEV Q&A · OPERATIONS

Keep deletes from vanishing

Handle NULL keys explicitly and make unknown-key tombstones a documented no-op.

Get the answers →

Last verified against official Databricks documentation: October 1, 2026. Correction history: first publication.

Latest means ranked by event time

A CDC change window is a timeline of events, not a list of rows. One key can carry insert → update → delete inside a single window, and feeding that straight into MERGE INTO fails with DELTA_MULTIPLE_SOURCE_ROW_MATCHING_TARGET_ROW_IN_MERGE.

The official MERGE INTO docs put it plainly: “Only a single row from the source table can match a given row in the target table” — and when multiple source rows match, “such an update operation is ambiguous as it is unclear which source row should be used to update the matched target row.”

The prescribed fix is to preprocess the change dataset and retain only the latest change for each key before applying it. The key word is latest: latest means ranked by event time, not merely deduped. Dedupe without ordering picks an arbitrary survivor.

Setup

CREATE OR REPLACE TABLE cdc_staging (
  customer_id INT, name STRING, email STRING, op_type STRING, event_time TIMESTAMP, seq INT
);
INSERT INTO cdc_staging VALUES
  (1, 'Ada',    'ada@x.com', 'insert', TIMESTAMP '2026-10-01 10:00:00', 1),
  (1, 'Ada L.', 'ada@x.com', 'update', TIMESTAMP '2026-10-01 10:05:00', 2),
  (2, 'Bob',    'bob@x.com', 'insert', TIMESTAMP '2026-10-01 10:01:00', 3),
  (2, NULL,     NULL,        'delete', TIMESTAMP '2026-10-01 10:06:00', 4),
  (3, 'Cy',     'cy@x.com',  'insert', TIMESTAMP '2026-10-01 10:02:00', 5),
  (4, NULL,     NULL,        'delete', TIMESTAMP '2026-10-01 10:03:00', 6);

CREATE OR REPLACE TABLE customers (customer_id INT, name STRING, email STRING);
INSERT INTO customers VALUES
  (1, 'Ada', 'ada@old.com'),
  (2, 'Bob', 'bob@x.com');

Before

customers holds: (1, 'Ada', 'ada@old.com') and (2, 'Bob', 'bob@x.com').

Step 1 — rank to one row per key

The latest change event wins.

CREATE OR REPLACE TEMP VIEW latest_changes AS
SELECT customer_id, name, email, op_type, event_time
FROM (
  SELECT *,
         ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY event_time DESC, seq DESC) AS rn
  FROM cdc_staging
  WHERE customer_id IS NOT NULL
) ranked
WHERE rn = 1;
Why the NULL-key filter matters: NULL keys can never match in an ON t.customer_id = s.customer_id join because NULL is never equal to NULL.

The window implements a deterministic tiebreaker after event_time via seq, the source sequence number. If two events share a timestamp, the higher seq wins.

Step 2 — merge with op-type dispatch

MERGE INTO customers AS t
USING latest_changes AS s
ON t.customer_id = s.customer_id
WHEN MATCHED AND s.op_type = 'delete' THEN DELETE
WHEN MATCHED THEN UPDATE SET t.name = s.name, t.email = s.email
WHEN NOT MATCHED AND s.op_type IN ('insert', 'update')
THEN INSERT (customer_id, name, email) VALUES (s.customer_id, s.name, s.email);

Clause-ordering note: with multiple WHEN MATCHED clauses, all except the last must carry conditions — this pattern obeys that rule. Explicit column lists are used, not INSERT * or UPDATE SET *, so the extra op_type and event_time columns in the source cannot break the write.

Validate per key

Use assertions per key, never row-count equality: a batch with delete tombstones legitimately changes row counts.

SELECT customer_id, COUNT(*)
FROM latest_changes
GROUP BY customer_id
HAVING COUNT(*) > 1;  -- expect zero rows

SELECT *
FROM customers
WHERE customer_id IN (
  SELECT customer_id FROM latest_changes WHERE op_type = 'delete'
);  -- expect zero rows

After

customers holds: (1, 'Ada L.', 'ada@x.com') and (3, 'Cy', 'cy@x.com'). Key 1 was updated to its latest event, key 2 was removed by its tombstone, key 3 was inserted, and key 4's tombstone was a clean no-op because the target never knew key 4.

That no-op is the op_type guard doing its job. Without it, an unconditional insert would have resurrected a deleted row.

Never put WHEN NOT MATCHED BY SOURCE on a change window. Absence from a change window means “unchanged,” not “deleted” — using it would rewrite the target on every run.

On Lakeflow Declarative Pipelines, stop hand-rolling: use AUTO CDC / APPLY CHANGES, which sequences out-of-order events for you.

  1. Stage the change window as a table: business key, payload columns, op_type, event_time.
  2. Rank to one row per key: ROW_NUMBER() OVER (PARTITION BY key ORDER BY event_time DESC, <deterministic tiebreaker>) = 1.
  3. Filter NULL keys — they can never match.
  4. MERGE: matched + delete tombstone → DELETE; matched → UPDATE with explicit columns; not matched + insert/update → INSERT with explicit columns.
  5. Never use WHEN NOT MATCHED BY SOURCE on a change window.
  6. On Lakeflow, use AUTO CDC / APPLY CHANGES instead of hand-rolling.

Use this when

  • You hand-roll CDC MERGE in SQL or notebooks.
  • A change window can hold multiple events per key.
  • Delete tombstones are part of the feed.
  • You are not on Lakeflow.

Avoid this when

  • You are on Lakeflow Declarative Pipelines — use AUTO CDC / APPLY CHANGES.
  • The source is a single full-snapshot load — a plain MERGE without ranking is fine.
  • The target is not a Delta table.
  • You are tempted to use dropDuplicates(['key']) as a “latest event” picker.
  • You are tempted to add WHEN NOT MATCHED BY SOURCE to a change window.
My source has no duplicate keys, but MERGE still throws DELTA_MULTIPLE_SOURCE_ROW_MATCHING_TARGET_ROW_IN_MERGE. Why?
The CDC events per key are the duplicates: an insert plus an update for one key is two source rows matching one target row. The error is about ambiguity — which event should win — not about dirty data. Rank the events, then merge.
Can I just use dropDuplicates(['key']) instead of the window function?
No. dropDuplicates keeps an arbitrary survivor — nothing guarantees it is the latest event. Rank explicitly with ROW_NUMBER() ordered by event time.
My deletes never land. The tombstones just vanish.
Two usual causes. First, NULL keys never match, so tombstones with NULL keys silently fall through — filter non-null keys or match null-safe. Second, a tombstone for a key the target never saw falls into NOT MATCHED: without the op_type guard on the INSERT branch, an unconditional insert would resurrect the deleted row as a new row. The guard makes it a documented no-op.
Assumptions: Unity Catalog / Delta Lake tables; SQL examples with the same semantics in PySpark; standard compute; Databricks Runtime 12.2 LTS+ only if you use WHEN NOT MATCHED BY SOURCE — this issue's pattern does not use it.
Provenance: Last verified against official Databricks documentation: October 1, 2026. Correction history: first publication.

See also Issue 006, “One key, one row: dedupe the source before you MERGE”, for the plain duplicate-key variant of this failure. This issue covers ordered CDC change windows, a different problem.

RECOMMENDED LEARNING

Read the contract. Then make the ordering explicit.

Start with the official MERGE guides, then practice production data-engineering patterns.

OFFICIAL DOCS

Upsert into a Delta Lake table using merge

Operational guidance for upserts, deduplication, incremental syncs, and CDC with Delta MERGE.

Read the guide →
SQL REFERENCE

MERGE INTO

The clause-ordering, matching, delete, update, and insert contract for Databricks SQL.

Read the reference →

Partner links: we may earn a commission if you enroll through these recommendations, at no extra cost to you.