cancel
Showing results for 
Search instead for 
Did you mean: 
Community Articles
Dive into a collaborative space where members like YOU can exchange knowledge, tips, and best practices. Join the conversation today and unlock a wealth of collective wisdom to enhance your experience and drive success.
cancel
Showing results for 
Search instead for 
Did you mean: 

Preventing Duplicate Records When Reprocessing Data in Databricks

Islam_hoti
New Contributor III

A pipeline can finish successfully and still produce the wrong result after a retry. Imagine an orders load that writes its data, then fails during a later task. Repeating the load with an append can add the same orders again. Replacing existing rows without checking their version can introduce a different problem: an older delivery can overwrite a newer order status.
In this article, I walk through a small PySpark and Delta Lake example that handles repeated deliveries and older updates. The target stores the latest state of each order. Reprocessing the same input should leave that state unchanged. This property is called idempotency.
Start with the meaning of a duplicate
Consider these incoming records:
|order_id|source_version|status |
|--------|--------------|-------|
|101 |1 |Created|
|101 |2 |Shipped|
|101 |2 |Shipped|
|102 |1 |Created|
The two identical records for order 101 at version 2 are duplicates. Versions 1 and 2 represent different states of the same order. Because the target stores current state, the intended result is one row for order 101 with status Shipped and one row for order 102 with status Created.
For this example, the source must provide a non-null order ID and an increasing version per order. Every event contains the complete order state. A given order ID and version must always describe the same content, including across separate deliveries. Deletes, partial updates, and concurrent writers are outside this example.
Prepare the source before MERGE
Delta Lake MERGE supports inserting new rows and updating existing ones. However, duplicate source rows can still be inserted when their key is absent from the target. Multiple source rows that attempt to update the same target row can also make the operation ambiguous. Prepare one source row per target key before merging. Databricks MERGE documentation
The preparation below removes exact duplicates, rejects conflicting versions within the batch, and selects the highest version for each order. The window assigns row numbers in descending version order. Apache Spark row_number documentation
Processing repeated and older order events
Figure 1. Prepare one candidate per order, then compare it with the target. Equal or older versions leave the target unchanged.
Implement the pattern in PySpark
Run this example in a Databricks Python notebook with Delta Lake support. Select a sandbox catalog and schema where you can create tables. The setup creates a new demonstration table and deliberately stops if that table already exists. To repeat the entire example, choose another unused table name. To test a replay, rerun only the calls to merge_orders.
```python
from delta.tables import DeltaTable
from pyspark.sql import functions as F, Window
TARGET = "replay_safe_orders_demo"
SCHEMA = "order_id LONG, source_version LONG, status STRING"
if spark.catalog.tableExists(TARGET):
raise ValueError("Choose an unused demo table name before setup.")
spark.sql(f"CREATE TABLE {TARGET} ({SCHEMA}) USING DELTA")
def merge_orders(batch_df):
source = batch_df.select("order_id", "source_version", "status")
invalid = source.filter(
F.col("order_id").isNull()
| F.col("source_version").isNull()
| F.col("status").isNull()
)
if invalid.limit(1).count():
raise ValueError("Required source fields cannot be null.")
unique = source.dropDuplicates()
conflicts = (
unique.groupBy("order_id", "source_version")
.count()
.filter(F.col("count") > 1)
)
if conflicts.limit(1).count():
raise ValueError("One order version has conflicting payloads.")
window = Window.partitionBy("order_id").orderBy(
F.col("source_version").desc()
)
latest = (
unique.withColumn("rn", F.row_number().over(window))
.filter(F.col("rn") == 1)
.drop("rn")
)
(
DeltaTable.forName(spark, TARGET).alias("t")
.merge(latest.alias("s"), "t.order_id = s.order_id")
.whenMatchedUpdateAll(
condition="s.source_version > t.source_version"
)
.whenNotMatchedInsertAll()
.execute()
)
batch = spark.createDataFrame([
(101, 1, "Created"),
(101, 2, "Shipped"),
(101, 2, "Shipped"),
(102, 1, "Created"),
], SCHEMA)
merge_orders(batch)
merge_orders(batch) # Replay the same delivery.
older = spark.createDataFrame([(101, 1, "Created")], SCHEMA)
merge_orders(older) # An older version must not replace Shipped.
actual = [tuple(row) for row in spark.table(TARGET)
.select("order_id", "source_version", "status")
.orderBy("order_id").collect()]
assert actual == [(101, 2, "Shipped"), (102, 1, "Created")]
```
The expected final result contains two rows. The assertion checks both the records and their values, so it detects an incorrect status as well as an unexpected duplicate. Collecting the result is appropriate for this tiny example; use distributed comparisons for large datasets.
Why the version condition matters
The match uses only order_id. A new key is inserted. For an existing key, the update condition permits only a strictly higher source_version. An equal version is a replay, and a lower version is an older delivery. Both leave the stored row unchanged. Conditional updates are part of the documented MERGE semantics. Databricks MERGE documentation
This design depends on the source contract. If the producer changes a payload while reusing a version, the example cannot reliably resolve that conflict across deliveries. Validate that contract upstream, or maintain an event history that allows those conflicts to be detected.
Applying the idea to streaming
For a custom Structured Streaming pipeline, a similar merge can run inside foreachBatch. Databricks documents at-least-once write guarantees for that callback, so its write logic must tolerate repeated execution. A checkpoint alone does not make arbitrary callback side effects idempotent. Databricks foreachBatch documentation
A complete streaming implementation also needs a durable checkpoint, empty-batch handling, and appropriate consumption of stateful query output. For CDC requirements involving deletes, history, or out-of-order changes, evaluate Lakeflow AUTO CDC rather than extending this small demonstration into a general CDC engine. Databricks CDC guidance
Tests to add before production
|Test |Expected behavior |
|----------------------------------------------------|-----------------------------|
|Repeat the same delivery |No change to current state |
|Repeat a row within a batch |One candidate for that order |
|Send an older version later |Keep the newer target version|
|Send a genuinely newer version |Update the order |
|Send conflicting payloads for one version in a batch|Reject the batch |
|Send a null key or version |Reject the batch |
Keep the target unique by order ID and coordinate its writers. This example starts with an empty table and one writer; it does not repair historical target duplicates or enforce uniqueness across independent writers. Retain raw deliveries separately when you need an audit trail, and monitor rejected records instead of silently discarding them.
The practical lesson is to define both identity and ordering before writing the merge. A business key answers which record an event belongs to. A source version answers whether that event should replace the state already stored.
How do you handle repeated deliveries and older updates in your Databricks pipelines? Do you use custom MERGE logic or Lakeflow AUTO CDC?

Islam_hoti_0-1789559784775.png

 

1 REPLY 1

Khasim_1
New Contributor III

Hi @Islam_hoti ,

The Core Pattern: Identity + Ordering

The article argues that idempotency is not achieved by simple "Appends." Instead, it requires two specific definitions:

  1. Identity: A business key (e.g., order_id) that tells you which entity the record belongs to.
  2. Ordering: A source version (e.g., source_version, event_timestamp) that tells you if the incoming data is newer than what is already stored.

Key Takeaways for Implementation

1. The Pre-Merge Preparation (Crucial Step)

You cannot simply run MERGE on raw input because raw input often contains duplicates or conflicting events within the same batch. The article provides a 3-step cleanup before any data touches the target:

  • Deduplication: unique = source.dropDuplicates() removes exact, byte-for-byte duplicate rows.
  • Conflict Resolution: It checks if multiple versions of the same order appear in the same batch. If so, it fails the batch, forcing the upstream producer to clean their data (a "fail-fast" approach).
  • Windowing: It uses Window.partitionBy("order_id").orderBy(F.col("source_version").desc()) to ensure only the single latest version from the incoming batch is considered for the merge.

2. The Conditional MERGE (The Idempotency Engine)

The magic happens in the whenMatchedUpdateAll condition:

python
.whenMatchedUpdateAll(
    condition="s.source_version > t.source_version"
)

This is the "idempotency guard."

  • Replays: If s.source_version == t.source_version, the condition is false; no update occurs.
  • Late/Old Data: If s.source_version < t.source_version, the condition is false; no update occurs.
  • Genuine Updates: Only when the source version is strictly higher does the MERGE commit the change.

When to use this vs. Lakeflow AUTO CDC

The article makes an important distinction:

  • Use the MERGE pattern (shown in the code): When you have complete control over the source contract, your logic is relatively simple (updates/inserts), and you are building a custom pipeline.
  • Use Lakeflow AUTO CDC: If your requirements grow to include deletes, full history tracking, or complex out-of-order event handling. AUTO CDC handles the sequencing and "pre-image/post-image" logic automatically, which saves you from writing (and maintaining) complex MERGE statements.

Practical Advice for your Team:

If you are currently struggling with data drift or incorrect states after job retries, this pattern is your fix.

Immediate next steps for your team:

  1. Stop writing "Append-Only": If your source system allows updates, append-only logic will eventually corrupt your reporting.
  2. Implement the Pre-Merge logic: Never merge raw incoming batches. Always perform the row_number() windowing step to ensure you are merging exactly "one candidate per order" into the target.
  3. Adopt the "Source Version" Contract: If your source system doesn't have a source_version or updated_at timestamp, you cannot implement idempotency. You must insist that upstream producers provide a versioning column.
Data Architect | 13 Years Domain Expertise | Databricks SA Champion Cohort