Back to Spark Streaming & Structured Streaming
Q6Question 6 of 22

Checkpointing in Structured Streaming

Key Concepts
  • Checkpointing persists source offsets, committed offsets, state store, and metadata to reliable storage like S3 or HDFS.
  • Write-ahead offset logging plus idempotent sinks deliver exactly-once processing across failures.
  • Each streaming query needs its own unique checkpoint path; sharing corrupts both queries.
  • On restart, Spark restores state and replays only the uncommitted batch, so running counts continue rather than resetting.
  • Incompatible changes like altering aggregation keys or stateful operators require a fresh checkpoint path.
  • The RocksDB state store supports incremental checkpointing, keeping checkpoint write cost proportional to the delta rather than total state.

Definition

The checkpoint persists read offsets, committed offsets, state, and metadata to reliable storage so a streaming query resumes exactly-once after a failure, but incompatible query changes can make an existing checkpoint unusable.
  • Checkpoint location – Configured with option("checkpointLocation", path) on durable storage like S3 or HDFS; each streaming query needs its own distinct path.
  • Offset tracking – The checkpoint stores the source offsets that have been read (write-ahead) and the offsets that have been fully committed, enabling exactly-once replay on failure.
  • State persistence – For stateful queries, the contents of the state store (running counts, window states, join buffers) are versioned per batch and written to the checkpoint.
  • Recovery – On restart, Spark reads the checkpoint, resumes from the last committed offsets, and restores state, so no data is skipped or double-counted.
  • Incompatible changes – Changing the aggregation keys, state schema, or number of stateful operators can make an existing checkpoint unusable, requiring a fresh checkpoint path.

At the National Bank of India the checkpoint is like a continuously updated logbook that records exactly which transaction slips have been read, which have been fully posted, and the current running tallies. If the clerk collapses mid-shift, a replacement reads the logbook, sees precisely where to resume, and recovers the tallies so no slip is double-posted or skipped. But if you rewrite the whole posting procedure and then hand the replacement the old logbook, the entries may no longer make sense under the new procedure.

Understanding the Concepts

What is checkpointing in Structured Streaming?

Checkpointing is the mechanism that persists a streaming query's progress and state to reliable storage so the query can recover exactly where it left off after a failure or restart. You configure it with the checkpointLocation option, and the checkpoint directory stores offsets, commits, state, and query metadata.

JAVA
Dataset<Row> counts = transactions
    .withWatermark("eventTime", "10 minutes")
    .groupBy("accountId")
    .count();

counts.writeStream()
    .outputMode("update")
    .format("console")
    .option("checkpointLocation", "s3a://nbi-checkpoints/account-counts")
    .start()
    .awaitTermination();

Why must the checkpoint be on durable storage?

The checkpoint must survive the loss of any single executor or the driver, so it must be on reliable, distributed storage such as HDFS or S3, not local disk. Every streaming query needs its own distinct path; sharing one path between two queries corrupts both.

JAVA
// CORRECT -- separate paths on durable storage
countQuery.writeStream()
    .option("checkpointLocation", "s3a://nbi-checkpoints/count-query")
    .start();

sumQuery.writeStream()
    .option("checkpointLocation", "s3a://nbi-checkpoints/sum-query")
    .start();

// WRONG -- sharing a checkpoint path
countQuery.writeStream()
    .option("checkpointLocation", "s3a://nbi-checkpoints/shared")
    .start();
sumQuery.writeStream()
    .option("checkpointLocation", "s3a://nbi-checkpoints/shared") // CORRUPT!
    .start();

How does offset tracking give exactly-once?

Before processing a batch, Spark writes the offset range to the offsets log (write-ahead). After the batch is fully processed and written to the sink, Spark writes to the commits log. On restart, Spark checks the last offsets and commits entries: if a batch was started but not committed, it reprocesses that batch, and because the sink is idempotent or transactional, reprocessing does not duplicate output.

DIAGRAM
Checkpoint directory structure:
  s3a://nbi-checkpoints/account-counts/
    ├── offsets/          # Write-ahead offset ranges per batch
    │   ├── 0             # Batch 0: offsets [0, 1042]
    │   ├── 1             # Batch 1: offsets [1043, 2150]
    │   └── 2             # Batch 2: offsets [2151, 3200]
    ├── commits/          # Fully committed batch markers
    │   ├── 0
    │   └── 1             # Batch 2 not committed -- will be replayed
    ├── state/            # State store snapshots
    │   └── 0/            # Partition 0
    │       ├── 1.delta
    │       └── 2.delta
    └── metadata          # Query ID, schema, plan

How does state recovery work?

For stateful queries, the state store (running counts, window aggregates, join buffers) is versioned per batch and written into the checkpoint. On restart Spark restores the state as of the last committed batch, so running counts continue from where they were rather than resetting to zero.

JAVA
// Before crash:
//   Batch 5 committed: accountId=ACC-001 count=47
//   Batch 6 started but NOT committed

// After restart:
//   State restored to Batch 5: ACC-001 count=47
//   Batch 6 replayed from offsets [5201, 6100]
//   ACC-001 count correctly becomes 52 (not reset to 0)

What changes are compatible with an existing checkpoint?

Some changes are safe across restarts, while others break the checkpoint.

| Change Type | Compatible? | Notes |
|---|---|---|
| Adjust trigger interval | Yes | Does not affect state or plan |
| Change non-stateful filter value | Yes | Stateless operators are flexible |
| Add a new non-stateful column | Yes | Does not affect state schema |
| Change aggregation keys | No | State is keyed differently |
| Add or remove stateful operators | No | State schema mismatch |
| Change output mode (with state change) | No | Plan incompatibility |
| Change watermark delay | Usually yes | Does not change state schema |

JAVA
// Safe change: adjust trigger from 10s to 30s
counts.writeStream()
    .trigger(Trigger.ProcessingTime("30 seconds")) // was 10 seconds
    .option("checkpointLocation", "s3a://nbi-checkpoints/account-counts")
    .start(); // Resumes from existing checkpoint

// Unsafe change: add branchCode to groupBy
transactions.groupBy("accountId", "branchCode").count() // NEW key
    .writeStream()
    .option("checkpointLocation", "s3a://nbi-checkpoints/account-counts")
    .start(); // FAILS -- state schema mismatch

What happens with incompatible changes?

When you make an incompatible change, you must start from a fresh checkpoint path. This means the state resets and you either reprocess from the beginning or accept a gap.

JAVA
// Breaking change: new aggregation key
// Step 1: Stop old query
// Step 2: Start new query with a NEW checkpoint path
transactions.groupBy("accountId", "branchCode").count()
    .writeStream()
    .option("checkpointLocation",
        "s3a://nbi-checkpoints/account-branch-counts-v2") // NEW path
    .option("startingOffsets", "earliest") // Reprocess if needed
    .start();

What about checkpoint write latency?

For large state, the per-batch checkpoint write can dominate batch duration. The RocksDB state store supports incremental checkpointing, uploading only changed state files, which keeps checkpoint cost proportional to the delta rather than the total state.

JAVA
// Configure RocksDB for incremental checkpoints
spark.conf().set(
    "spark.sql.streaming.stateStore.providerClass",
    "org.apache.spark.sql.execution.streaming.state.RocksDBStateStoreProvider"
);
// Now checkpoint writes are incremental -- only changed state files
// Large state (100M keys) checkpoints in seconds instead of minutes

What are common mistakes?

The most common mistake is changing stateful query logic and restarting from the same checkpoint, expecting it to work. Another mistake is using local disk for the checkpoint path, which loses the checkpoint when the driver node is replaced.

JAVA
// WRONG -- local disk checkpoint (lost on node failure)
counts.writeStream()
    .option("checkpointLocation", "/tmp/checkpoints/counts") // LOCAL!
    .start();

// CORRECT -- durable storage
counts.writeStream()
    .option("checkpointLocation", "s3a://nbi-checkpoints/counts")
    .start();

What should a senior engineer know?

Teams should version their checkpoint paths and plan migrations deliberately. For a breaking change, run the new query against a new checkpoint while backfilling, rather than pointing it at the old one and hoping. Monitor checkpoint write latency as a metric, because for large state it can dominate batch duration. Treat the checkpoint as durable, query-specific, and sensitive to logic changes.

Visual Overview

DIAGRAM
  Checkpoint Recovery Flow

  Normal Operation:
  ┌──────────┐   ┌──────────┐   ┌──────────┐
  │ Batch 0  │──▶│ Batch 1  │──▶│ Batch 2  │
  │ offsets: │   │ offsets: │   │ offsets: │
  │ [0,1042] │   │[1043,2150]│  │[2151,3200]│
  │ commit:OK│   │ commit:OK│   │ commit:? │ ◀── CRASH
  └──────────┘   └──────────┘   └──────────┘
       │              │              │
       ▼              ▼              ▼
  ┌──────────────────────────────────────┐
  │  Checkpoint (S3/HDFS)                │
  │  offsets/0  offsets/1  offsets/2     │
  │  commits/0  commits/1  (no 2)       │
  │  state/version-1  state/version-2   │
  └──────────────────────────────────────┘

  After Restart:
  ┌──────────────────────────────────────┐
  │  1. Read checkpoint                  │
  │  2. Last committed = Batch 1         │
  │  3. Restore state to version 1       │
  │  4. Replay Batch 2 from offsets      │
  │     [2151, 3200]                     │
  │  5. Idempotent sink ignores dupes    │
  │  6. Commit Batch 2                   │
  │  7. Resume normal processing         │
  └──────────────────────────────────────┘

Key Takeaway

Checkpointing in Structured Streaming is the mechanism that persists a query's progress and state to reliable storage so it can recover exactly where it left off. You configure it with the checkpointLocation option pointing at durable storage like S3 or HDFS, and the checkpoint directory stores the offsets read, the offsets committed, the state store for stateful operations, and query metadata.

Each batch is written ahead before processing and committed after the sink write, so on restart Spark reprocesses only a batch that was started but not committed, and because the sink is idempotent or transactional, that reprocessing does not duplicate output. For stateful queries the state store is restored from the checkpoint so running counts and join buffers continue rather than resetting.

The main operational hazard is changing the query logic and restarting from an existing checkpoint. Spark records the plan and the state schema, so some changes are safe, like adjusting a trigger interval, while others are incompatible, like changing aggregation keys. For a breaking change you must start from a fresh checkpoint, which means reprocessing or accepting a state reset.

At the National Bank of India every streaming query had its own S3 checkpoint path, never shared, and after a crash the per-account count job resumed from the last committed batch with its running counts intact. When a deployment changed the aggregation key, the team pointed the new query at a new checkpoint path and backfilled, rather than reusing the old one and risking corruption.

Remember one sentence: The checkpoint persists offsets, state, and metadata for exactly-once recovery, must live on durable storage with one path per query, and will reject an incompatible query change, so plan checkpoint migrations as part of every breaking deployment.

---

Share
Use ← → arrow keys to navigate