Back to posts
Post

Safe External Writes in Spark Structured Streaming with foreachBatch

Learn how to use foreachBatch for idempotent, exactly-once writes to external systems in Spark Structured Streaming — avoiding duplicates and data loss.

Big DataSpark Structured StreamingforeachBatchexactly-onceidempotent write

When I first started using Spark Structured Streaming to write data to external systems like databases or APIs, I kept seeing duplicate records after job restarts. At first, I blamed the network or the sink connector. But after digging into checkpointing and micro-batch behavior, I realized the issue wasn’t the stream — it was how I was writing. That’s when foreachBatch became my go-to pattern for safe, reliable writes.

In Spark Structured Streaming, foreachBatch lets you process each micro-batch as a regular DataFrame or Dataset using standard batch operations. This is powerful because it gives you full control over the write logic — including transactions, idempotency checks, and exactly-once semantics — without giving up the benefits of streaming.

Here’s how I implement it in practice. First, I define a function that takes a batch DataFrame and its batch ID:

void writeBatch(DataFrame batchDF, long batchId) {
  if (batchDF.isEmpty()) return;

  // Use batchId for idempotency — e.g., store processed batch IDs in a deduplication table
  val processed = spark.sql("SELECT batch_id FROM streaming_metadata WHERE batch_id = $batchId")
  if (processed.count() > 0) {
    log.info("Batch $batchId already processed. Skipping.")
    return;
  }

  try {
    batchDF.write
      .format("jdbc")
      .option("url", jdbcUrl)
      .option("dbtable", "target_table")
      .option("user", username)
      .option("password", password)
      .mode(SaveMode.Append)
      .save()

    // Mark batch as processed only after successful write
    spark.sql("INSERT INTO streaming_metadata (batch_id, processed_at) VALUES ($batchId, current_timestamp())")
  } catch {
    case e: Exception =>
      log.error("Failed to write batch $batchId", e)
      throw e  // Let Streaming handle retry via checkpoint
  }
}

Then I attach it to the stream:

streamingDF.writeStream
  .foreachBatch(writeBatch _)
  .outputMode("Update")
  .option("checkpointLocation", "/mnt/checkpoints/streaming_write")
  .start()

The key here is using the batchId — automatically provided by Structured Streaming — to detect and skip already-processed micro-batches. This makes the write idempotent. Combined with write-ahead logs and checkpointing, it delivers exactly-once guarantees even if the job fails mid-batch.

I’ve seen teams try to use foreach or mapPartitions for external writes, but those don’t give you batch-level control. Without batchId, you can’t safely track what’s been committed. I learned this the hard way after a Kafka connector glitch caused 20 minutes of duplicate invoices to hit our billing system.

A few practical tips:

  • Always use a durable metadata store (like a database table or Delta Lake) to track processed batch IDs — not in-memory sets.
  • Keep the idempotency check lightweight; avoid heavy queries that slow down the batch.
  • If your external system supports transactions (e.g., PostgreSQL, SQL Server), wrap the write and metadata update in a single transaction.
  • Never use SaveMode.Overwrite in foreachBatch unless you’re intentionally replacing a partition — it’s risky and defeats the purpose of streaming.

As I mentioned before in my post about monitoring systemd slices, observability is critical — so I always add metrics for batch processing time, success/failure counts, and lag. Pair that with alerting on stalled streams, and you’ve got a production-ready pipeline.

If you’re writing to systems that don’t natively support exactly-once (like REST APIs or legacy DBs), foreachBatch with idempotency keys is often the most practical path forward. It’s not magic — it’s just disciplined batch processing inside a streaming framework. And in my environment, that’s what keeps the data accurate and the alerts silent.


Cover image: learn_tek · CC0 (Openverse / kamu malı) · https://www.flickr.com/photos/153724200@N07/27774351928