Skip to main content

Snapshots, Time Travel, and Rollback

Every committed change to a Gnok table — INSERT, UPDATE, DELETE, MERGE, COMPACT, OPTIMIZE — produces a new Iceberg snapshot. Snapshots are immutable, ordered by a monotonic sequence number, and chained by parent_snapshot_id. The "live" state of a table is whichever snapshot the main branch ref points at; reads and writes both target it. Time-travel reads, rollback, and metadata-only recovery all hang off this one model.

This page ties together the four surfaces that touch snapshots:

  1. SHOW SNAPSHOTS — DDL command for human inspection.
  2. <table>$snapshots — table-valued metadata view (filterable).
  3. Time-travel reads (AT(SNAPSHOT => …) / FOR SYSTEM_VERSION AS OF).
  4. Rollback (ALTER TABLE … SET SNAPSHOT n / ROLLBACK TABLE … TO SNAPSHOT n).

The mental model​

                                          main ref
│
▼
S1 (append) ──► S2 (overwrite) ──► S3 (delete) ← head, what SELECT reads
seq=1 seq=2 seq=3
parent=NULL parent=S1 parent=S2
  • snapshot_id — globally-unique 64-bit id, derived from the millisecond timestamp + entropy.
  • sequence_number — monotonically-increasing per commit. Use this to order snapshots, not timestamp — multiple commits at the same millisecond will collide on time but never on sequence.
  • parent_snapshot_id — chains back to the predecessor; NULL for the very first snapshot.
  • is_current — true only for the snapshot the main ref points at. After a rollback, this is not necessarily the newest by timestamp.

Rollback does not create a new snapshot — it atomically flips the main ref. The rolled-over snapshots stay in history (you can roll forward again, or read them via time-travel) until the expire-snapshots policy prunes them.


Inspecting snapshots​

SHOW SNAPSHOTS — human-readable​

SHOW SNAPSHOTS FROM bb.hr.orders;

Returns one row per snapshot:

ColumnTypeNotes
snapshot_idBIGINTThe id you'd pass to ROLLBACK / ALTER TABLE … SET SNAPSHOT
is_currentBOOLEANTRUE for the active head
parent_snapshot_idBIGINTNULL for the first snapshot
sequence_numberBIGINTUse this to order
timestampBIGINTms since epoch
operationVARCHARappend, overwrite, delete, replace
manifest_listVARCHARS3 path

<table>$snapshots — queryable​

SHOW SNAPSHOTS is a DDL command and can't be subqueried. The matching table-valued metadata view supports WHERE, ORDER BY, LIMIT, and projection:

-- The active head, by name
SELECT snapshot_id, sequence_number, operation
FROM bb.hr.orders$snapshots
WHERE is_current;

-- The five most recent operations on the table
SELECT sequence_number, operation, snapshot_id
FROM bb.hr.orders$snapshots
ORDER BY sequence_number DESC
LIMIT 5;

-- Every append since a given timestamp
SELECT snapshot_id, sequence_number
FROM bb.hr.orders$snapshots
WHERE operation = 'append' AND timestamp > 1777700000000
ORDER BY sequence_number;

Supported predicates: bare boolean column refs, NOT, AND, OR, IS TRUE, IS FALSE, =, != against Boolean / VARCHAR / BIGINT columns. Anything fancier (joins, subqueries, GROUP BY) falls back to the unfiltered set rather than erroring.

The same evaluator runs on every <table>$<view> system table — $history, $files, $manifests, $partitions, $refs, $entries, $all_data_files, $all_delete_files, $all_manifests.

Every metadata view uses your own identity and organization, the same way ordinary SELECTs do. $files and $manifests (which open the table's manifest and data Parquet footers to populate columns like record_count, column_sizes, and partition values) read with the same storage access as your queries.

SELECTing the timestamp / committed_at columns from $snapshots and $history renders as an ISO-8601 string on the wire (the columns are TIMESTAMP(ms, UTC)).


Time-travel reads​

Read a table as it existed at a prior snapshot or timestamp. Doesn't mutate the table — it's a read-only view of a historical state.

-- Function-style syntax
SELECT * FROM bb.hr.orders AT(SNAPSHOT => 1777747402203);
SELECT * FROM bb.hr.orders AT(TIMESTAMP => '2025-01-01 12:00:00');

-- Standard SQL syntax
SELECT * FROM bb.hr.orders FOR SYSTEM_VERSION AS OF 1777747402203;

When the value is small (< 1 trillion) Gnok treats it as a snapshot id; larger values are interpreted as millisecond timestamps and resolved to the newest snapshot whose timestamp ≤ given value.

Use time-travel when you want to compare two states without disturbing the head — audit a prior point, validate a fix against historical data, repair a corrupt write by re-running a transform against the pre-bug snapshot, etc.


Rollback​

Move the main ref back to a prior snapshot. Atomic, metadata-only, no data files written. Two equivalent surface syntaxes — pick whichever you prefer:

-- DDL-style alias (recommended — no cognitive collision with the
-- transactional ROLLBACK; matches Spark/Iceberg's CALL form and
-- Delta's RESTORE family)
ALTER TABLE bb.hr.orders SET SNAPSHOT 1777747402203;
ALTER TABLE bb.hr.orders SET CURRENT SNAPSHOT 1777747402203;
ALTER TABLE bb.hr.orders SET VERSION AS OF 1777747402203;
ALTER TABLE bb.hr.orders SET TIMESTAMP AS OF 1777740000000;

-- Iceberg-flavoured ROLLBACK form. NOTE: bare `ROLLBACK`
-- is a transactional abort; the parser disambiguates `ROLLBACK TABLE`
-- by lookahead.
ROLLBACK TABLE bb.hr.orders TO SNAPSHOT 1777747402203;
ROLLBACK TABLE bb.hr.orders TO PARENT;
ROLLBACK TABLE bb.hr.orders TO TIMESTAMP 1777740000000;

Both forms map to the same handler — same atomicity, same effects.

TargetResolves to
SNAPSHOT <id> / SET SNAPSHOT <id>The named snapshot (must exist in history)
PARENTSecond-to-last snapshot in history (one step back from current)
TIMESTAMP <ms> / SET TIMESTAMP AS OF <ms>Newest snapshot whose timestamp ≤ given value
VERSION AS OF <id>Synonym for SNAPSHOT <id> (Delta-flavour)

Rollback ≠ time-travel​

Two important distinctions:

Time travelRollback
Mutates the table?No (read-only view)Yes (flips main ref)
Affects other readers?No — they still see the headYes — the head moved
Reversible?N/AYes — roll forward to the previous head
Use whenAuditing, comparing states, debuggingRecovering from a bad commit

Rollback is reversible​

The "rolled-over" snapshots (S2, S3 in the diagram below) stay in history. You can move the head forward again with another ALTER TABLE … SET SNAPSHOT:

   Before rollback:                After ALTER TABLE SET SNAPSHOT <S1>:
main main
│ │
▼ ▼
S1 ──► S2 ──► S3 S1 ──► S2 ──► S3
▲
│
S2 and S3 still in $snapshots,
is_current=False on both

A subsequent ALTER TABLE … SET SNAPSHOT <S3> flips the head back to S3. Until expire-snapshots prunes them, all three remain reachable.


End-to-end example​

-- 1. Build up history
CREATE TABLE bb.hr.demo_snap (id INT, name VARCHAR, salary INT);
INSERT INTO bb.hr.demo_snap VALUES (1, 'Alice', 100), (2, 'Bob', 80), (3, 'Carol', 90);
UPDATE bb.hr.demo_snap SET salary = 150 WHERE id = 1;
DELETE FROM bb.hr.demo_snap WHERE id = 2;

-- 2. Inspect: 3 snapshots, head is the DELETE
SELECT sequence_number, operation, is_current
FROM bb.hr.demo_snap$snapshots
ORDER BY sequence_number;
-- → 1, append, False
-- 2, overwrite, False
-- 3, delete, True

-- 3. Time-travel — read as of S1 without touching the head
SELECT id, name, salary
FROM bb.hr.demo_snap AT(SNAPSHOT => <S1_id>);
-- → (1, 'Alice', 100), (2, 'Bob', 80), (3, 'Carol', 90)

-- The live SELECT still sees the post-DELETE state
SELECT id, name, salary FROM bb.hr.demo_snap ORDER BY id;
-- → (1, 'Alice', 150), (3, 'Carol', 90)

-- 4. Roll back to S1 — head moves
ALTER TABLE bb.hr.demo_snap SET SNAPSHOT <S1_id>;

SELECT id, name, salary FROM bb.hr.demo_snap ORDER BY id;
-- → (1, 'Alice', 100), (2, 'Bob', 80), (3, 'Carol', 90)

-- 5. is_current has moved; S2/S3 still reachable
SELECT sequence_number, operation, is_current
FROM bb.hr.demo_snap$snapshots
ORDER BY sequence_number;
-- → 1, append, True
-- 2, overwrite, False
-- 3, delete, False

-- 6. Roll forward
ALTER TABLE bb.hr.demo_snap SET SNAPSHOT <S3_id>;
-- → back to (1, 'Alice', 150), (3, 'Carol', 90)

Cleanup: EXPIRE_SNAPSHOTS and VACUUM​

Snapshot history isn't free — every retained snapshot pins its manifest list and the data files it references. Use Iceberg's expire-snapshots and the engine's VACUUM TABLE to bound the storage cost:

-- Drop snapshots older than 7 days (keeps the metadata lineage,
-- removes the snapshot pointer)
ALTER TABLE bb.hr.orders SET TBLPROPERTIES (
'history.expire.max-snapshot-age-ms' = '604800000'
);

-- Run the maintenance loop
VACUUM TABLE bb.hr.orders;

After expiration:

  • Time-travel reads to expired snapshots fail with "snapshot not found".
  • Rollback to expired snapshots fails the same way.
  • Live SELECTs are unaffected — they read the active head.

See Table Maintenance for the full lifecycle (OPTIMIZE, VACUUM, EXPIRE_SNAPSHOTS, COMPACT).


When to use what​

GoalSurface
"What snapshots does this table have?"SHOW SNAPSHOTS FROM t
"Which is the current head?"SELECT … FROM t$snapshots WHERE is_current
"Show me the table as it was last Tuesday"SELECT … FROM t AT(TIMESTAMP => …)
"Audit two prior states without affecting readers"SELECT … FROM t AT(SNAPSHOT => S1) UNION ALL SELECT … FROM t AT(SNAPSHOT => S2)
"Recover from a bad UPDATE"ALTER TABLE t SET SNAPSHOT <pre_bad_id>
"Re-run a pipeline that wrote bad data, atomically"INSERT/UPDATE/DELETE on the table; if it goes sideways, ALTER TABLE t SET SNAPSHOT <last_good>

See also​