The checkpoint wins
An existing checkpoint resumes from its committed offsets. Reader options do not reset it.
See the rule →A restart follows the checkpoint, not startingOffsets. If Kafka retention has deleted those offsets, the query fails with KAFKA_DATA_LOSS. Diagnose before restarting, choose one deliberate recovery, and make the missing range visible.
Separate diagnosis from recovery: first prove the offset gap, then select a path whose data-loss semantics you can explain in an incident review.
An existing checkpoint resumes from its committed offsets. Reader options do not reset it.
See the rule →Inspect the checkpoint, compare broker bounds, choose one recovery, and prove continuity.
Run the playbook →No option, retry, or checkpoint edit can restore records already deleted by Kafka retention.
Read the answer →Last verified against official Databricks documentation: 2026-10-11. Correction history: first publication — no corrections.
When a Structured Streaming job restarts from an existing checkpoint, it resumes from the source offsets committed there — not from fresh reader options. startingOffsets applies only when a new query starts. If the application was down longer than Kafka retention, or retention is shorter than a batch's processing time, the checkpoint can point below the broker's earliest available offset.
At that point the query fails with KAFKA_DATA_LOSS (SQLSTATE 22000). A read below the broker's earliest available offset cannot succeed because those records no longer exist on Kafka. Changing startingOffsets on the existing checkpoint does nothing. Setting failOnDataLoss=false skips missing data; it does not re-read it.
failOnDataLoss=false, or backfill the gap from an independent replayable bronze Delta table. Every path must document the missing range.This baseline makes the two semantics explicit: startingOffsets="earliest" is honored only for a new query, while failOnDataLoss="true" is the default and stops the query when source data might be missing.
from pyspark.sql import functions as F
BOOTSTRAP_SERVERS = "kafka-1:9092,kafka-2:9092"
TOPIC = "events"
CHECKPOINT = "/Volumes/ops/streaming/checkpoints/events_bronze_v1"
kafka_events = (
spark.readStream
.format("kafka")
.option("kafka.bootstrap.servers", BOOTSTRAP_SERVERS)
.option("subscribe", TOPIC)
.option("startingOffsets", "earliest") # ONLY for a new query
.option("failOnDataLoss", "true") # default: fail loudly
.load()
.select(
F.col("topic"), F.col("partition"), F.col("offset"),
F.col("timestamp"), F.col("key").cast("string"),
F.col("value").cast("string")
)
)
query = (
kafka_events.writeStream
.format("delta")
.outputMode("append")
.option("checkpointLocation", CHECKPOINT)
.toTable("ops.kafka_bronze")
)
Stop the query and inspect the newest numeric file under the checkpoint's offsets/ directory. dbutils.fs.head exposes the checkpoint record; do not modify it. Separately obtain the current earliest offset for every topic partition from your Kafka administration or monitoring tooling. A committed offset below its partition's earliest available offset proves that Kafka retention has created an unrecoverable broker-side gap.
# GATE 1 — READ ONLY. Never edit files in this directory.
offset_dir = f"{CHECKPOINT}/offsets/"
offset_files = [f for f in dbutils.fs.ls(offset_dir) if f.name.rstrip("/").isdigit()]
latest = max(offset_files, key=lambda f: int(f.name.rstrip("/")))
print(f"Latest checkpoint offset file: {latest.path}")
print(dbutils.fs.head(latest.path, 65536))
# Parse the Kafka offset JSON from that output according to your checkpoint format.
# Then compare, partition by partition:
# checkpoint_committed_offset vs. broker_earliest_available_offset
# If committed < earliest, that Kafka range is gone.
ILLUSTRATIVE ERROR PATTERN — exact wording varies by runtime:
[KAFKA_DATA_LOSS.Could_Not_Read_Offset_Range] SQLSTATE: 22000
Reason: COULD_NOT_READ_OFFSET_RANGE
Could not read an offset range for topic partition events-2.
The checkpoint requested data below the broker's earliest available offset.
The class, SQLSTATE, and reason-code family are documented. The text above is an illustrative pattern, not a captured production stack trace.
Use a brand-new checkpoint path and startingOffsets="earliest". The query replays only the data that remains in Kafka. The expired range stays missed; record that gap before you proceed. Ensure the sink can tolerate replay or deduplicate by a stable event key.
CHECKPOINT_V2 = "/Volumes/ops/streaming/checkpoints/events_bronze_v2"
replay_retained = (
spark.readStream
.format("kafka")
.option("kafka.bootstrap.servers", BOOTSTRAP_SERVERS)
.option("subscribe", TOPIC)
.option("startingOffsets", "earliest") # honored: V2 is new
.option("failOnDataLoss", "true")
.load()
)
(replay_retained.writeStream
.format("delta")
.outputMode("append")
.option("checkpointLocation", CHECKPOINT_V2)
.toTable("ops.kafka_bronze"))
# The expired Kafka range is NOT recovered. Document it in ops.streaming_gaps.
Use failOnDataLoss=false only as a one-time, declared gap advance when the business accepts the missing range. It tells Spark to skip unavailable data and continue. It must never become a standing configuration: leaving it off turns future retention failures into silent data loss.
# One controlled recovery run against the existing checkpoint.
advance_once = (
spark.readStream
.format("kafka")
.option("kafka.bootstrap.servers", BOOTSTRAP_SERVERS)
.option("subscribe", TOPIC)
.option("failOnDataLoss", "false") # skips; does NOT re-read
.load()
)
(advance_once.writeStream
.format("delta")
.outputMode("append")
.trigger(availableNow=True)
.option("checkpointLocation", CHECKPOINT)
.toTable("ops.kafka_bronze"))
# After the declared advance, restore failOnDataLoss="true".
If an independently retained bronze table already contains the missing events, rebuild the affected downstream range from that table. This is the only path here that can fill data Kafka no longer retains. Record the incident and the exact recovery status in a durable operations table.
CREATE TABLE IF NOT EXISTS ops.streaming_gaps (
stream_name STRING NOT NULL,
topic STRING NOT NULL,
partition INT NOT NULL,
missing_from_offset BIGINT NOT NULL,
missing_to_offset_exclusive BIGINT NOT NULL,
detected_at TIMESTAMP NOT NULL,
recovery_path STRING NOT NULL,
recovery_status STRING NOT NULL,
evidence STRING,
recorded_by STRING
) USING DELTA;
-- ILLUSTRATIVE gap record only — replace every figure with Gate 1 evidence.
INSERT INTO ops.streaming_gaps VALUES (
'events_bronze', 'events', 2, 120000, 126500,
current_timestamp(), 'independent_bronze_backfill', 'backfilled',
'checkpoint committed=120000; broker earliest=126500', current_user()
);
-- Backfill from the independent retained table using your idempotent sink logic.
-- ILLUSTRATIVE filter shape; table and offset figures are environment-specific.
MERGE INTO silver.events AS target
USING (
SELECT * FROM archive.kafka_bronze
WHERE topic = 'events' AND partition = 2
AND offset >= 120000 AND offset < 126500
) AS source
ON target.event_id = source.event_id
WHEN NOT MATCHED THEN INSERT *;
Check the per-partition maximum offsets actually present in bronze, verify the gap record exists, and reconcile the recovered or declared range with the Gate 1 evidence. Do not call the stream recovered because it is merely running.
-- GATE 2A: latest persisted offset per topic partition.
SELECT topic, partition, MAX(offset) AS max_persisted_offset
FROM ops.kafka_bronze
GROUP BY topic, partition
ORDER BY topic, partition;
-- GATE 2B: the incident must have a durable, reviewable gap record.
SELECT stream_name, topic, partition,
missing_from_offset, missing_to_offset_exclusive,
recovery_path, recovery_status, detected_at
FROM ops.streaming_gaps
WHERE stream_name = 'events_bronze'
ORDER BY detected_at DESC;
-- GATE 2C (outside SQL): compare these results with current broker earliest/latest
-- offsets and the checkpoint's next committed offsets, partition by partition.
log.retention.ms and size-based retention beyond the maximum plausible downtime and processing lag; the more restrictive retention limit can win.failOnDataLoss=true for normal operation so the next loss is loud.KAFKA_DATA_LOSS, SQLSTATE 22000, and the documented reason code such as COULD_NOT_READ_OFFSET_RANGE.offsets/ file with dbutils.fs.head; compare committed with broker-earliest for every partition.failOnDataLoss=false.ops.streaming_gaps.No. No option, tuning change, or retry brings back Kafka records deleted by retention. startingOffsets is new-query-only; it is ignored when a stream resumes from an existing checkpoint. failOnDataLoss=false skips unavailable data rather than re-reading it. Hand-editing checkpoint JSON is undocumented folk practice, not a supported recovery. Backfill the missing range from an independent replayable source, or declare it unrecoverable: document the gap, advance deliberately, and fix retention plus monitoring before normal operation resumes.
KAFKA_DATA_LOSS after downtime or prolonged processing lag.failOnDataLoss=false enabled permanently; that converts future data loss into silent skipping.KAFKA_DATA_LOSS with SQLSTATE 22000; this issue makes no specific Databricks Runtime version claim.Strengthen the data-engineering fundamentals behind durable ingestion, replay, observability, and recovery.
Practice pipeline design, distributed processing, orchestration, and operational data engineering.
Browse DataCamp data engineering courses →Partner links: we may earn a commission if you enroll through these recommendations, at no extra cost to you.