Lakehouse Digest
ISSUE 007 · SEPTEMBER 26, 2026

The offset that vanished: recover a dead Kafka stream without silently losing data

A Kafka-backed stream restarts with “offset was changed from X to Y, some data may have been missed.” The checkpoint and the topic disagree — and the tempting one-line fix silently skips the gap instead of recovering it. The tactic: read the checkpoint's offsets file, restart on a versioned checkpoint, and backfill the missing window from an independent replayable raw source.

THE THREE-PART READ

One tactic. One pattern. One fix.

Built for engineers who need an answer they can test in the next notebook, job run, or incident review.

TIP · 3 MIN

Read the checkpoint before you touch the config

Inspect the checkpoint's committed offsets, then compare them with Kafka's current earliest offsets before deciding whether to restart or reset.

Read the diagnostic →
IMPLEMENTATION · 4 MIN

The weekend the topic outran the checkpoint

Reset on a versioned checkpoint, keep the old path for forensics, and backfill the missing window from an independent replayable raw source.

See the recovery pattern →
DEV Q&A · 2 MIN

I set startingOffsets='earliest' — why did nothing change?

The checkpoint outranks the config on a restart. Use the decision table to know when a new checkpoint is required.

Get the answer →

The error message is the whole diagnosis: the checkpoint recorded offset 48,210 for the partition, and the topic now offers something lower — or nothing at that position at all. Spark can't tell a recreated topic from aged-out retention, so it assumes the worst: data may have been lost. Failing loudly is the entire point of the default.

Three things cause it, and they all mean the same thing — the checkpoint and the topic describe different worlds: (1) the topic was deleted and recreated; (2) Kafka's retention aged the offsets out while the stream was stopped; (3) the topic was reconfigured — partitions added or moved — while the checkpoint pointed at the old layout. In every case, the offsets the stream wants no longer exist in Kafka.

import json

ckpt = "/Volumes/catalog/schema/vol/checkpoints/events_stream"

# The offsets/ directory holds one JSON file per committed batch:
# {"events": {"0": 48210, "1": 48177}}
offset_files = sorted(
    dbutils.fs.ls(ckpt + "/offsets/"), key=lambda f: int(f.name)
)
expected = json.loads(dbutils.fs.head(offset_files[-1].path))
print(json.dumps(expected, indent=2))

# Illustrative expected output:
# {"events": {"0": 48210, "1": 48177}}
How to validate: Compare the committed offsets against the topic's current earliest offsets. If the committed offsets are still inside Kafka's retention window, the checkpoint is fine — restart and the query resumes from it. If they're below the topic's earliest offset, no restart, retry, or config change helps: those records no longer exist in Kafka. The stream must be reset on a fresh checkpoint, and the gap must be backfilled from an independent replayable raw source. If no replayable source exists, the range is unrecoverable and must be declared explicitly.

The failure

Illustrative scenario: A team streams click events from a Kafka topic into a Delta bronze table — append mode, hourly trigger, checkpoint on a Unity Catalog volume. Friday evening they stop the job for a runtime upgrade. The topic's retention is 48 hours — fine for a normal weekend, but the upgrade slips to Monday morning. The restarted query dies immediately with the IllegalStateException. The checkpoint's last committed offsets (illustrative: partition 0 → 48,210) sit below the topic's new earliest offset (illustrative: 51,003) — about 2,800 offsets of events are simply gone from Kafka. Restarting again changes nothing: the offsets don't exist to resume from.

1. Diagnose before you reset

Run the offsets-file read from the tip and establish two numbers: what the checkpoint expects, and the topic's current earliest offset. If the expected offsets are still within the topic's range, restart as-is — the query resumes from the checkpoint. If they sit below the earliest offset, the data is gone from Kafka: reset, don't retry.

2. Reset with a versioned checkpoint — never reuse or delete the old path

# Illustrative values: replace KAFKA, catalog, and paths with your own
KAFKA = "kafka-broker1:9092"
CKPT  = "/Volumes/catalog/schema/vol/checkpoints/events_stream"

recovered = (spark.readStream.format("kafka")
    .option("kafka.bootstrap.servers", KAFKA)
    .option("subscribe", "events")
    .option("startingOffsets", "latest")  # new query: only new data from here
    .load()
    .selectExpr("CAST(key AS STRING) AS event_key",
                "CAST(value AS STRING) AS payload",
                "topic", "partition", "offset",
                "timestamp AS kafka_ts"))

q = (recovered.writeStream.format("delta")
    .outputMode("append")
    .option("checkpointLocation", CKPT + "_v2")  # versioned: never reuse _v1
    .trigger(processingTime="1 hour")
    .toTable("catalog.bronze.events"))

Two things matter here. startingOffsets=latest on the new query means no replay — it picks up only records arriving from here on. And the _v2 path keeps the reset auditable: _v1 stays behind as the forensic record of what the old query committed. Per the docs: “When you delete the files in a checkpoint directory or change to a new checkpoint location, the next run of the query begins fresh.”

3. Backfill the gap from a replayable source — never from Kafka — then dedupe before writing

# Illustrative independent replay source. Replace the path, format, columns,
# and timestamps with your landing-zone contract and observed missing window.
RAW_REPLAY = "/Volumes/catalog/raw/landing/events"
MISSING_FROM = "2026-09-25T18:00:00Z"
MISSING_TO   = "2026-09-28T08:00:00Z"

gap = (spark.read.format("json")
    .load(RAW_REPLAY)
    .where(f"event_time >= timestamp '{MISSING_FROM}' "
           f"AND event_time < timestamp '{MISSING_TO}'")
    .select("event_key", "payload", "event_time"))

# Dedupe by event key before writing. Valid only when event_key is unique
# per event; duplicate keys would collapse distinct events.
(gap.dropDuplicates(["event_key"])
    .write.format("delta").mode("append")
    .saveAsTable("catalog.bronze.events_backfill"))

The expired offsets (illustrative: 48,210 and 48,177) are below Kafka's earliest available offset (illustrative: 51,003) and cannot be read from Kafka. This batch read uses an explicitly illustrative independent raw landing-zone source, filtered to the missing event-time window. Dedupe by event key before writing because the backfill can overlap the new stream's first micro-batch; the dropDuplicates is valid only when the event key is unique per event — duplicate keys would collapse distinct events. Validate the backfilled row count against the gap size (illustrative: ~2,800) before unioning it into the bronze table.

Use this when: Kafka retention expired during downtime and an independent replayable raw source exists for the missing event-time window.

If no replayable source exists: the range is unrecoverable — declare the gap explicitly, document the missing window, advance the checkpoint past it deliberately (never silently skip it), and fix retention and monitoring so it does not recur.

Avoid this when: the committed offsets are still inside Kafka's retention window — just restart; or your only changes were filters, rate limits, trigger intervals, or UDF logic, which the docs list as safe on the same checkpoint.

Never set failOnDataLoss=false in production to clear this error. It converts a loud, diagnosable failure into silent skipping. Keep the default true so the failure stays loud.

SAVE THIS — The checkpoint-recovery checklist
  1. Read the checkpoint's latest offsets/N file — know exactly what the stream expected.
  2. Compare against the topic's current earliest offsets: inside retention → restart as-is; below earliest → reset.
  3. Reset with a versioned checkpoint path (_v2, _v3…) — never by deleting or reusing the old one; set startingOffsets=latest on the new query.
  4. Backfill the gap from the replayable raw source (expired Kafka offsets cannot be re-read from Kafka); dedupe by event key before writing.
  5. One checkpoint per query, always — sharing a checkpoint between queries corrupts both.

Streaming recovery you can't afford to get wrong? I run checkpoint-recovery reviews — the offsets audit, the retention math, and the backfill drill, on your pipeline. Get in touch through the contact in About.

DEV Q&A · 2 MIN

I set startingOffsets='earliest' — why did nothing change?

The config changed. The stream ignored it. Here's why — and the decision table for what actually needs a new checkpoint.

The decision table
  1. startingOffsets only applies when a NEW streaming query starts. A restart from an existing checkpoint ignores the option entirely and resumes from the committed offsets — silently. Your config change did nothing because the checkpoint outranks it. If you want different offsets, you need a new checkpoint first (the _v2 reset in the pattern above) — then the option takes effect on that new query.

  2. The docs draw a hard line on which query changes force a new checkpoint (“Recover after changes in a Structured Streaming query”): new checkpoint required for the number or type of input sources, subscribed Kafka topics or Auto Loader paths, stateful operation types, state schema, or output sink type. Safe to keep the checkpoint: adding or removing filters, rate limits (e.g. maxOffsetsPerTrigger), trigger intervals, or UDF logic inside mapGroupsWithState (semantics may change).

  3. Put together: changed the topics or the sink and want different offsets? New checkpoint, then set the offsets on the new query. Only tuned the trigger or a filter? Keep the checkpoint — a restart is enough. And hand-editing the offsets/ file — “checkpoint surgery” — is the third option some engineers reach for: it can work for stateless micro-batch reads, but the checkpoint also holds commits/ and state, and editing one file breaks their consistency. The versioned reset is the supported path.

Applies to: Databricks Runtime 14.x and above (Spark 3.5+); Structured Streaming Kafka source (self-managed Kafka, MSK, Confluent, or Event Hubs' Kafka endpoint); Delta Lake sinks in append output mode. The scenario and all figures in it are illustrative. Last verified September 30, 2026 against official Databricks and Apache Spark documentation. Corrections: Sep 30, 2026 — corrected the Kafka batch-read step: expired offsets cannot be re-read from Kafka; backfill must come from a replayable source, otherwise the range is unrecoverable.

ABOUT

About Lakehouse Digest.

Contact: sbogale@sb9tech.com

Lakehouse Digest is a short, practical newsletter for Databricks engineers, published by the Lakehouse Digest team. Each issue delivers one tactic to try, one production pattern to understand, and one hard developer question answered with code and primary-source links. It is an independent publication and is not affiliated with Databricks, Inc.

RECOMMENDED LEARNING

Build the skill behind the fix.

Topic-led learning paths for readers who want to turn this issue’s tactics into repeatable Databricks practice.

GO DEEPER

Stream from Kafka into Delta Lake

The official guide behind this issue: Kafka source options, batch reads with explicit offsets, and the Kafka-to-Delta example.

Read the Databricks docs →
BUILD FOR PRODUCTION

Data engineering workflows

Pipelines, orchestration, and streaming practice that turn this issue's tactics into repeatable habits.

Browse DataCamp data engineering →

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

THE NEWSLETTER

One useful Databricks issue, built for the working session.

Concise implementation notes, real failure modes, and developer answers—without a news recap you could get anywhere.