All posts

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.

8 min read Iceberg

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_snapshot returned it to 3.
  • A rollback does not erase history. The history table kept the abandoned snapshot with is_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

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.

Buy me a coffee