Lakehouse Digest
ISSUE 024 · OCTOBER 12, 2026

Your foreachBatch wrote the batch twice — at-least-once is the contract, not a bug

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.

THE THREE-PART READ

The retry is the feature. The duplicates were optional.

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.

TIP

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.

See the rule →
IMPLEMENTATION

Reproduce the double-write, then fix it

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 →
DEV Q&A

Does MERGE need the transaction pair?

The pair guards appends. MERGE idempotency is logical: a deduped replay must converge to the same state, with the empty-batch guard intact.

Read the answer →

Last verified against official Databricks documentation: 2026-10-12. Correction history: first publication — no corrections.

The same batch can arrive twice

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.

The retry is the contract. The duplicates are the part you were supposed to handle.

Illustrative simulation

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.

Setup — safe to rerun

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"

Step 1 — the broken handler

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()
Deterministic result: before either call, COUNT(*) = 0. After the first call, 3. After the simulated retry, 6. The batch landed twice. Nothing in the write remembers batch 7.

Step 2 — bind the Delta transaction version to the batch id

# 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()
Deterministic result: 0 → 3 → still 3. The duplicate delivery was identified by its (txnAppId, txnVersion) pair and ignored.

Step 3 — the checkpoint-reset trap

# 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()
Deterministic result: the first three calls produce 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.
  1. Empty-batch guard first: if batch_df.isEmpty(): return — Delta sources can emit empty micro-batches.
  2. Append path: use .option("txnAppId", APP_ID).option("txnVersion", batch_id) — bind the version to the batch id, never a random UUID.
  3. Keep APP_ID stable per checkpoint lineage; a new checkpoint gets a new app id.
  4. MERGE path: dedupe the source to one row per merge key; the merge must converge to the same state when the same batch replays.
  5. External sinks: key idempotency on batch_id — same batch id in, same result out.
  6. Errors: fail fast to the orchestrator; catch only transient sink errors (retry or DLQ) or suppress duplicate-key violations on an idempotent sink.
  7. One sink per writer for throughput: multiple sinks in one foreachBatch serialize writes.
  8. Stay in micro-batch mode: never use foreachBatch with continuous processing mode, and never assume a green micro-batch means the retry is a no-op.
I MERGE inside foreachBatch instead of appending — do I still need txnAppId/txnVersion?

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.

Can I just catch the exception inside the handler and retry the write there?

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.

I deleted the checkpoint to unstick a stream, restarted it, and now rows are missing — what happened?

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".

Use this when

  1. Delta MERGE from a stream: Databricks documentation requires foreachBatch for streaming merge operations.
  2. Sinks without streaming writers: Cassandra, a REST API, custom Delta logic, or a dead-letter-queue split — foreachBatch is the documented bridge, with batch_id-keyed idempotency.
  3. Orchestrator-retried batches: Lakeflow Jobs or Airflow can retry while the handler stays simple and fail-fast.
  4. You can name the idempotency key: stable APP_ID per checkpoint lineage, with batch_id as the version.

Avoid this when

  1. A native streaming sink exists: .toTable() is simpler, parallel, and exactly-once through the checkpoint and Delta transaction log.
  2. You need multiple sinks at speed: writes in one foreachBatch serialize; use one streaming writer per sink.
  3. You use continuous processing mode: foreachBatch relies on micro-batch execution; use foreach() there.
  4. You plan local retry loops with swallowed exceptions: they convert retried batches into silent partial writes.
Structured Streaming micro-batch queries on Databricks Runtime (any recent LTS). On DBR 14.0+ with standard access mode, 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.
The demo stages a DataFrame as the micro-batch and simulates the retry by calling the handler twice with the same batch_id, matching the engine's documented re-delivery behavior. It is not applicable to continuous processing mode.
Last verified against official Databricks documentation: 2026-10-12. Correction history: first publication — no corrections.
RECOMMENDED LEARNING

Build the streaming foundation.

Structured practice for designing retries, idempotent sinks, and production streaming pipelines.

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

Coming soon. Email signup is not active yet. Browse the issue archive in the meantime.