Make the batch identity durable
foreachBatch is at-least-once: bind txnVersion to batch_id with a stable txnAppId, or the retry appends everything twice.
foreachBatch promises at-least-once writes, not exactly-once. When a micro-batch fails after committing, the engine re-delivers the same batch_id and a plain append doubles every row. Bind txnVersion to batch_id with a stable txnAppId on Delta targets, key external sinks on batch_id — and give every new checkpoint lineage a new app id, or restarted batches are silently skipped.
A green micro-batch is not a promise the retry will be a no-op. Design the handler as if Spark will call it again with the same batch_id: guard empty batches, bind txnVersion to batch_id, fail fast to the orchestrator — and never reuse a txnAppId across checkpoint lineages.
foreachBatch is at-least-once: bind txnVersion to batch_id with a stable txnAppId, or the retry appends everything twice.
Three rows become six without transaction options and stay three with them — plus the checkpoint-reset trap where a reused app id silently skips restarted batches.
Run the simulation →The pair guards appends. MERGE idempotency is logical: a deduped replay must converge to the same state, with the empty-batch guard intact.
Last verified against official Databricks documentation: 2026-10-12. Correction history: first publication — no corrections.
foreachBatch promises at-least-once writes — not exactly-once. When a micro-batch fails after its writes commit (or the query restarts mid-batch), the engine re-delivers the same batch_id and your handler runs again from the top. A plain .mode("append") write has no memory of the first delivery, so the retry doubles every row.
The fix is writer-level idempotency: on Delta targets, pass .option("txnAppId", APP_ID).option("txnVersion", batch_id). Delta records the (app, version) pair in the transaction log and ignores a re-delivered batch. For anything outside Delta — an API, a database, or a queue — key your own idempotency on the batch_id: same batch id in, same result out, no matter how many times the engine calls you.
Not a live streaming run: the engine's documented retry is simulated by calling the batch handler twice with the same batch_id — exactly what a re-delivered micro-batch looks like. Row counts below are deterministic from the setup, not measured production figures.
spark.sql("""
CREATE OR REPLACE TABLE demo.orders_sink (
order_id STRING,
amount INT
) USING DELTA
""")
batch_df = spark.createDataFrame(
[("ord1", 100), ("ord2", 200), ("ord3", 300)],
"order_id STRING, amount INT",
)
APP_ID = "orders-streaming-job-v1"
def write_batch_broken(batch_df, batch_id):
if batch_df.isEmpty():
return
(batch_df.write
.format("delta")
.mode("append")
.saveAsTable("demo.orders_sink"))
# Simulate the original delivery and its retry.
write_batch_broken(batch_df, 7)
write_batch_broken(batch_df, 7)
spark.sql("SELECT COUNT(*) AS rows FROM demo.orders_sink").show()
COUNT(*) = 0. After the first call, 3. After the simulated retry, 6. The batch landed twice. Nothing in the write remembers batch 7.# Fresh target: this keeps the simulation idempotent and isolates Step 2.
spark.sql("""
CREATE OR REPLACE TABLE demo.orders_sink (
order_id STRING,
amount INT
) USING DELTA
""")
APP_ID = "orders-streaming-job-v1"
def write_batch(batch_df, batch_id):
if batch_df.isEmpty():
return
(batch_df.write
.format("delta")
.mode("append")
.option("txnAppId", APP_ID)
.option("txnVersion", batch_id)
.saveAsTable("demo.orders_sink"))
write_batch(batch_df, 7)
write_batch(batch_df, 7)
spark.sql("SELECT COUNT(*) AS rows FROM demo.orders_sink").show()
0 → 3 → still 3. The duplicate delivery was identified by its (txnAppId, txnVersion) pair and ignored.# Fresh target for this isolated reset simulation.
spark.sql("""
CREATE OR REPLACE TABLE demo.orders_sink (
order_id STRING,
amount INT
) USING DELTA
""")
APP_ID = "orders-streaming-job-v1"
# Original checkpoint lineage: versions 0, 1, and 2 commit nine rows.
for b in range(3):
write_batch(batch_df, b)
# A new checkpoint starts batch ids at 0, but APP_ID was mistakenly reused.
new_batch = spark.createDataFrame(
[("ord9", 900)],
"order_id STRING, amount INT",
)
write_batch(new_batch, 0)
spark.sql("SELECT COUNT(*) AS rows FROM demo.orders_sink").show()
9 rows. The restarted batch is silently skipped, so the count stays 9 and ord9 never lands. Fix: a new checkpoint lineage gets a new app id — for example, APP_ID = "orders-streaming-job-v2", or embed the deployment/run id.if batch_df.isEmpty(): return — Delta sources can emit empty micro-batches..option("txnAppId", APP_ID).option("txnVersion", batch_id) — bind the version to the batch id, never a random UUID.APP_ID stable per checkpoint lineage; a new checkpoint gets a new app id.batch_id — same batch id in, same result out.foreachBatch serialize writes.foreachBatch with continuous processing mode, and never assume a green micro-batch means the retry is a no-op.The transaction pair is the DataFrameWriter-level guard for append-style writes. A MERGE keyed on a unique business key is idempotent by logic: re-applying the same micro-batch converges to the same rows. The non-negotiable companion rule is a deduped source — a MERGE whose source has two rows for one key can fail on ambiguous matches (see Issues 006 and 013). Keep the isEmpty guard: Delta sources can deliver empty micro-batches.
No — that is the documented anti-pattern for Delta targets. Let errors propagate so Spark metrics stay accurate, data does not duplicate, and the orchestrator cleanly retries the full batch. Blessed catches only: transient sink errors (retry or dead-letter queue) and duplicate or key-constraint violations on an idempotent sink (log and suppress). Logic errors, schema mismatches, permission errors, and OOMs should propagate and fail fast.
The checkpoint reset restarted batch ids at 0 while the target table still remembered the old (txnAppId, txnVersion) pairs. Delta treats the app id and batch id as the unique key and skips pairs it has already seen, so restarted batches were silently skipped. The documented rule: a new checkpoint lineage must use a different txnAppId. Bake the lineage into the id, such as "orders-streaming-job-v2".
foreachBatch for streaming merge operations.foreachBatch is the documented bridge, with batch_id-keyed idempotency.APP_ID per checkpoint lineage, with batch_id as the version..toTable() is simpler, parallel, and exactly-once through the checkpoint and Delta transaction log.foreachBatch serialize; use one streaming writer per sink.foreachBatch relies on micro-batch execution; use foreach() there.print() inside the handler goes to driver logs and referenced objects must be serializable. Target: Delta Lake tables; txnAppId and txnVersion are Delta writer options.batch_id, matching the engine's documented re-delivery behavior. It is not applicable to continuous processing mode.Structured practice for designing retries, idempotent sinks, and production streaming pipelines.
Structured practice for streaming pipeline patterns.
Browse DataCamp data engineering courses →Partner links: we may earn a commission if you enroll through these recommendations, at no extra cost to you.