Lakehouse – Phase 5 Snapshots and Time Travel

Every mutation to a lakehouse catalog — INSERT, DELETE, ALTER TABLE — produces a new snapshot. A snapshot is a monotonically increasing integer ID that represents the state of all tables, columns, and data files at that instant. Nothing is overwritten; metadata rows carry visibility ranges that say when each row was “born” and when it was “retired.” This is the foundation of time travel: query any historical state by passing a different snapshot ID to the same visibility filter.

Here is the roadmap for the phases to come:

  • Phase 5: Snapshots and time travel
  • Phase 6: Schema evolution
  • Phase 7: Deletes and updates
  • Phase 8: Storage and access

Full Source Code

The code referenced in this post can be found in https://gitlab.com/kimserey.lam/lake-learn.

Why Snapshots Matter

In a traditional database, a “snapshot” usually refers to an ephemeral MVCC view. In a lakehouse, the concept is more permanent: each snapshot is a committed version of the entire catalog, persisted in metadata and queryable indefinitely.

Without snapshots, a lakehouse has no way to answer “what did this table look like yesterday?” and no way to guarantee that a long-running scan sees consistent data while new files are being added concurrently.

Iceberg solves this with a chain of manifest-list files on object storage — each manifest list is effectively a snapshot pointer. Delta Lake uses a JSON transaction log. DuckLake replaces all of that with a single SQL pattern: WHERE begin_snapshot <= N AND (end_snapshot IS NULL OR end_snapshot > N).

In DuckLake’s C++ implementation, snapshot lifecycle is managed by DuckLakeTransaction (src/storage/ducklake_transaction.cpp). The DuckLakeSnapshot struct holds snapshot_id, schema_version, next_catalog_id, and next_file_id.

The Snapshot Table

1
2
3
4
5
6
7
8
9
10
ducklake_snapshot
┌─────────────┬─────────────────────┬────────────────┬─────────────────┬──────────────┐
│ snapshot_id  │ snapshot_time       │ schema_version │ next_catalog_id │ next_file_id │
├─────────────┼─────────────────────┼────────────────┼─────────────────┼──────────────┤
│ 0           │ 2025-05-25 10:00:00 │ 0              │ 1               │ 1            │
│ 1           │ 2025-05-25 10:00:01 │ 1              │ 3               │ 1            │
│ 2           │ 2025-05-25 10:01:00 │ 1              │ 3               │ 2            │
│ 3           │ 2025-05-25 10:02:00 │ 1              │ 3               │ 3            │
│ 4           │ 2025-05-25 10:05:00 │ 2              │ 4               │ 3            │
└─────────────┴─────────────────────┴────────────────┴─────────────────┴──────────────┘

snapshot_id — monotonically increasing. The latest snapshot is the current state.

schema_version — incremented when any schema-altering operation occurs (ADD/DROP/RENAME COLUMN, CREATE TABLE). Data-only changes (INSERT, DELETE) do not bump this.

next_catalog_id — next available ID for catalog objects (tables, schemas, columns). Used for gap-free ID allocation.

next_file_id — next available ID for data and delete files.

Our Go struct mirrors this:

1
2
3
4
5
6
7
type Snapshot struct {
    ID            int64
    Time          time.Time
    SchemaVersion int64
    NextCatalogID int64
    NextFileID    int64
}

Visibility Ranges

Every metadata row that can change over time has two columns:

1
2
begin_snapshot  BIGINT NOT NULL   -- snapshot when this row became active
end_snapshot    BIGINT            -- snapshot when retired (NULL = still active)

A row is visible at snapshot N when:

1
begin_snapshot <= N AND (end_snapshot IS NULL OR end_snapshot > N)

This is a half-open interval: [begin_snapshot, end_snapshot).

1
2
3
4
5
6
7
8
9
10
11
Timeline:  snap 0    snap 1    snap 2    snap 3    snap 4    snap 5
           ──────────────────────────────────────────────────────────

File A:    ├─────────────────────────────────────────────────────▶  (begin=0, end=NULL)
           Active from snap 0, still active

File B:              ├────────────────────────────┤                 (begin=1, end=4)
                     Active from snap 1 through snap 3

Column X:  ├─────────────────────┤                                  (begin=0, end=3)
           Active from snap 0 through snap 2 (dropped at snap 3)

Tables affected by visibility ranges: ducklake_table, ducklake_column, ducklake_data_file, ducklake_delete_file, and ducklake_schema.

In Go, visibility checking is a simple function:

1
2
3
4
5
6
7
8
9
func IsVisible(beginSnap int64, endSnap *int64, querySnap int64) bool {
    if querySnap < beginSnap {
        return false
    }
    if endSnap != nil && querySnap >= *endSnap {
        return false
    }
    return true
}

How a Snapshot Is Created

Every mutation follows this pattern:

1
2
3
4
5
6
7
8
9
10
1. BEGIN Postgres transaction
2. Perform the operation:
   - INSERT: write Parquet file, register in ducklake_data_file
   - DELETE: write delete file, register in ducklake_delete_file
   - ALTER:  update ducklake_column rows
3. Read the current latest snapshot to get next IDs
4. Compute snapshot_id = latest_snapshot_id + 1
5. INSERT into ducklake_snapshot (new snapshot row)
6. INSERT into ducklake_snapshot_changes (audit record)
7. COMMIT Postgres transaction

The Postgres transaction guarantees atomicity. Compare this with the alternatives:

1
2
3
4
5
6
7
8
9
DuckLake (database-backed):
  BEGIN; INSERT file; INSERT snapshot; COMMIT;
  → Single atomic database transaction

Iceberg (manifest-based):
  Write manifest-001.avro to S3
  Write manifest-list-002.avro to S3
  Atomically swap the "current" pointer
  → Requires careful rename semantics, retries on conflict

Time Travel

Time travel means querying a table as it existed at a previous snapshot. The only change in the scan pipeline is which snapshot ID gets passed to the metadata queries.

1
2
3
4
5
6
7
8
Latest snapshot (5):
  Files visible:  [001.parquet (begin=1), 003.parquet (begin=3)]
  Columns active: [id, name, total]   (amount renamed to total at snap 4)

At snapshot 3:
  Files visible:  [001.parquet (begin=1), 002.parquet (begin=2)]
  Columns active: [id, name, amount]  (not yet renamed)
  Delete files:   none yet            (delete happened at snap 5)

The scan method already supports this via ScanOptions.SnapshotID:

1
2
3
4
5
type ScanOptions struct {
    Columns    []string
    Predicates []Predicate
    SnapshotID *int64      // nil = latest snapshot
}

When SnapshotID is set, the scan uses that instead of the latest:

1
2
3
4
5
6
var snapID int64
if opts.SnapshotID != nil {
    snapID = *opts.SnapshotID
} else {
    snapID, _ = l.latestSnap(ctx)
}

All downstream metadata queries use this snapID in the visibility filter. Nothing else changes.

The Snapshot Manager

The Manager type provides snapshot queries:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Manager struct {
    store *catalog.MetadataStore
}

func (m *Manager) LatestSnapshot(ctx context.Context) (*Snapshot, error) {
    // SELECT ... FROM ducklake_snapshot ORDER BY snapshot_id DESC LIMIT 1
}

func (m *Manager) SnapshotAt(ctx context.Context, version int64) (*Snapshot, error) {
    // SELECT ... FROM ducklake_snapshot WHERE snapshot_id = $1
}

func (m *Manager) ListSnapshots(ctx context.Context) ([]Snapshot, error) {
    // SELECT ... FROM ducklake_snapshot ORDER BY snapshot_id ASC
}

ListSnapshots returns the full version history — useful for audit trails and debugging.

The Snapshot Changes Table

Each snapshot records what happened:

1
2
3
4
5
6
7
8
9
10
ducklake_snapshot_changes
┌─────────────┬────────────────────────────────────────────────────┐
│ snapshot_id  │ changes_made                                      │
├─────────────┼────────────────────────────────────────────────────┤
│ 1           │ create schema "main"                               │
│ 2           │ create table main.orders (3 columns)               │
│ 3           │ insert 100 rows into main.orders                   │
│ 4           │ add column "status" to main.orders                 │
│ 5           │ delete 5 rows from main.orders                     │
└─────────────┴────────────────────────────────────────────────────┘

Every transaction calls txn.RecordChange(description) before committing, and the commit method flushes these to the changes table.

Transaction Isolation

When a reader starts a scan, it reads at the latest snapshot at that moment. Even if another writer commits a new snapshot during the scan, the reader continues to see the old snapshot:

1
2
3
4
5
6
7
8
9
10
Time →

Reader starts scan at snapshot 5
  ├── Queries metadata: files WHERE begin_snapshot <= 5 ...
  ├── Reads file A
  │
  │   Writer commits snapshot 6 (adds file C)
  │
  ├── Reads file B
  └── Done. File C was never visible to this reader.

This works because each metadata query includes the snapshot filter. The reader never sees snapshot 6’s files because they have begin_snapshot = 6, which fails begin_snapshot <= 5.