Time travel and rollback in Iceberg: undoing a bad write in one statement
Every Iceberg commit leaves a snapshot, so reading the table as it was is a query and undoing a bad write is a procedure call. A rollback took a table from 5 rows back to 3, and the history table kept a record of the snapshot that was abandoned.
- Every commit is a restore point
- Reading an old state
- Undoing a bad write
- The tables that answer questions
- Retention is your recovery window
- Finding the snapshot you want
- Incremental reads between snapshots
- Write-audit-publish
- Retention, as a policy
- Branches and tags
- Common misconceptions
- A model worth keeping
- References
- Trademarks
TL;DR
- Reading an old state is a query:
VERSION AS OF <snapshot_id>returned 2 rows on a table whose current state had 3.- Undoing a bad write is one call. After two accidental rows took a table from 3 to 5,
rollback_to_snapshotreturned it to 3.- A rollback does not erase history. The
historytable kept the abandoned snapshot withis_current_ancestor=false.- Your recovery window is exactly your snapshot retention. Expiring snapshots deletes the ability to go back, which is why expiry is a recovery policy, not a cleanup chore.
- Snapshots are cheap because they share metadata, not because they are free — each commit still writes three metadata files.
Every commit is a restore point
Iceberg never edits a snapshot. A commit writes new metadata describing the new state and leaves the previous metadata intact, so every commit the table has ever made is still described somewhere, as long as it has not been expired.
Two things follow: you can read any retained state, and you can return to it.
Reading an old state
A table with two commits — two rows, then a third:
SELECT snapshot_id, committed_at FROM local.db.tt.snapshots ORDER BY committed_at;
snapshots: [5422029406646937356, 9044140986145286505]
current rows = 3
VERSION AS OF first = 2
SELECT count(*) FROM local.db.tt VERSION AS OF 5422029406646937356;
Two rows, because that is what the table held at that snapshot. The same works by time:
SELECT * FROM local.db.tt TIMESTAMP AS OF '2026-09-24 10:00:00';
which resolves to the snapshot current at that moment.
This is a normal query. It plans the same way against different metadata, so there is no special slow path — the read-path funnel just starts from a different snapshot.
Undoing a bad write
The case this exists for. Two rows land that should not have:
after bad write = 5
CALL local.system.rollback_to_snapshot('db.tt', 9044140986145286505);
after rollback = 3
One statement, back to three rows. No restore from backup, no rewriting, no downtime — the catalog pointer now names the earlier snapshot, and readers see that state immediately.
There is a companion for the timestamp case,
rollback_to_timestamp, and set_current_snapshot for moving to an arbitrary
snapshot rather than strictly backwards.
A rollback is itself recorded
history rows:
snap=5422029406646937356 is_current_ancestor=True
snap=9044140986145286505 is_current_ancestor=True
snap=5042529038394299375 is_current_ancestor=False
snap=9044140986145286505 is_current_ancestor=True
Four entries for three snapshots. The bad snapshot 5042529038394299375 is still
listed, flagged is_current_ancestor=false — it happened, and it is no longer in
the current table’s lineage. The final entry records the table being pointed back
at the earlier snapshot.
Rollback does not erase history; it changes which history is current. That matters for auditing: the record that a bad write occurred, and was reverted, survives.
It also means a rollback is itself reversible — until the abandoned snapshot is expired, you can roll forward again.
The tables that answer questions
| Table | Question |
|---|---|
.snapshots |
what commits exist, what operation, what changed |
.history |
what the current state’s lineage is, including abandoned branches |
.files |
which data and delete files the current snapshot references |
.manifests |
how much metadata planning must read |
.refs |
named branches and tags |
.metadata_log_entries |
every metadata file written |
The operation column on .snapshots is the first place to look when a table
changed unexpectedly: append, overwrite, delete and replace tell you what
kind of write happened, and summary carries record counts.
Retention is your recovery window
CALL local.system.expire_snapshots(
table => 'db.tt',
older_than => TIMESTAMP '2026-09-17 00:00:00',
retain_last => 10);
That statement deletes your ability to go back. It removes snapshots and the data files only they referenced, which is the only operation that reclaims storage — and simultaneously the operation that shortens how far back you can recover.
So retention is two numbers at once: storage cost and recovery window. Seven days of snapshots is seven days in which a bad write can be undone, and seven days of storage for every file those snapshots reference.
Decide it as a recovery policy. “How long before we would certainly have noticed a bad write?” is the right question, and the storage follows from the answer rather than driving it.
Snapshots are affordable because manifest reuse means each one describes only what changed — but they are not free, since each commit still writes three metadata files. A table committing every minute accumulates fast, and its retention needs to reflect commit frequency rather than calendar intuition.
Finding the snapshot you want
Rolling back means naming a snapshot, and “the one before the bad write” has to be found rather than guessed.
-- what happened, most recent first
SELECT snapshot_id, parent_id, operation,
summary['added-records'] AS added,
summary['deleted-records'] AS deleted,
summary['total-records'] AS total,
committed_at
FROM local.db.events.snapshots
ORDER BY committed_at DESC LIMIT 20;
The operation column narrows it immediately: a table that should only ever be
appended to, showing an overwrite or delete, has found its culprit. The
summary map carries the record deltas, so a commit that removed far more rows
than expected is visible without reading any data.
To see the damage before undoing it, diff two snapshots:
SELECT count(*) AS rows_then FROM local.db.events VERSION AS OF 9044140986145286505;
SELECT count(*) AS rows_now FROM local.db.events;
Or compare aggregates per partition, which localises the problem:
SELECT partition, sum(record_count) AS rows
FROM local.db.events.files GROUP BY partition ORDER BY partition;
Incremental reads between snapshots
Time travel’s less-known sibling: reading only what changed between two snapshots, which is how downstream pipelines avoid rescanning a table.
(spark.read
.format("iceberg")
.option("start-snapshot-id", "5422029406646937356")
.option("end-snapshot-id", "9044140986145286505")
.load("local.db.events")
.count())
This returns the rows appended between those snapshots. Two caveats that
matter: it covers appends only — an incremental read spanning an overwrite or
delete raises rather than silently under-reporting — and both snapshots must
still exist, so retention bounds how far behind a consumer may fall.
The pattern for a downstream job is to store the last snapshot id it processed, then read forward from it:
last = read_watermark() # your own state store
cur = spark.sql("SELECT snapshot_id FROM local.db.events.snapshots "
"ORDER BY committed_at DESC LIMIT 1").collect()[0][0]
if last != cur:
df = (spark.read.format("iceberg")
.option("start-snapshot-id", last)
.option("end-snapshot-id", cur)
.load("local.db.events"))
process(df)
write_watermark(cur)
Write-audit-publish
Branches turn “check before anyone sees it” into a mechanism rather than a convention.
-- 1. write to a branch nobody reads
ALTER TABLE local.db.events CREATE BRANCH `audit`;
(df.writeTo("local.db.events").option("branch", "audit").append())
-- 2. run your checks against the branch
SELECT count(*) FROM local.db.events VERSION AS OF 'audit';
-- 3. publish only if they pass
CALL local.system.fast_forward('db.events', 'main', 'audit');
If the checks fail, drop the branch and nothing ever reached readers:
ALTER TABLE local.db.events DROP BRANCH `audit`;
This is the difference between catching a bad load and rolling one back. A rollback is visible to anyone who queried in between; a failed audit branch is not.
Retention, as a policy
ALTER TABLE local.db.events SET TBLPROPERTIES (
'history.expire.max-snapshot-age-ms' = '604800000', -- 7 days
'history.expire.min-snapshots-to-keep' = '50');
Two settings, because age alone is a poor policy for a table whose commit rate
varies. min-snapshots-to-keep guarantees a floor: a table that went quiet for a
week still has 50 restore points rather than none.
Tags pin individual snapshots beyond the general policy, which is what you want for a month-end close or a model-training input:
ALTER TABLE local.db.events CREATE TAG `close-2026-09`
AS OF VERSION 9044140986145286505 RETAIN 365 DAYS;
Check what is pinned before wondering why expiry reclaimed less than expected:
SELECT name, type, snapshot_id, max_reference_age_in_ms FROM local.db.events.refs;
Branches and tags
For states that should outlive ordinary retention, name them:
ALTER TABLE local.db.tt CREATE TAG `month-end-2026-09`
AS OF VERSION 9044140986145286505 RETAIN 365 DAYS;
A tag pins a snapshot with its own retention, so an audit point survives a
7-day expiry policy. A branch does the same for a line of development, letting
you write to audit or wap without affecting main.
That is also the mechanism behind write-audit-publish: write to a branch, run checks, then fast-forward the main reference only if they pass.
Common misconceptions
“Time travel keeps a copy of the table.” It keeps metadata pointing at files that already exist. Old snapshots share files with new ones.
“Rollback deletes the bad data.” It changes which snapshot is current. The
abandoned snapshot stays in history until expired.
“I can always go back.” Only as far as your retention. Expiry is irreversible.
“Time travel queries are slow.” They start from different metadata and plan identically.
“Expiring snapshots is routine cleanup.” It is deleting your recovery window and should be set deliberately.
A model worth keeping
Every commit leaves a snapshot; the catalog points at one of them. Time travel reads a different one, rollback points at a different one, and expiry destroys the ones you no longer keep.
Which makes the retention setting the most consequential number on a production table — it is simultaneously your storage bill and your undo button.
References
- Iceberg Spark queries for
VERSION AS OFandTIMESTAMP AS OF - Iceberg Spark procedures for
rollback_to_snapshotand friends - Iceberg branching and tagging for named references and write-audit-publish
- Life of a write query in Iceberg for the commits that create these snapshots
- Table maintenance for Iceberg and Hudi for what expiry reclaims
Trademarks
Apache Iceberg, Apache Spark, Apache and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation in the United States and other countries.
Found this useful?
These posts and tools are free. If one saved you an afternoon, you can buy me a coffee.