skip to content

How does LanceDB versioning work, and how do you read an older table version?

level: middleimportance: must knowfreq 65%

answer

  1. writes commit, they do not mutate
  2. every operation adds a snapshot
  3. list, then pin, then return to head
  4. pinned tables are read-only
  5. rollback moves forward, not backward

basics

~20 s

Every write to a LanceDB table — append, update, delete, overwrite, index build — commits a new immutable version instead of mutating data in place. table.list_versions() enumerates them, table.checkout(n) pins the table object to version n for reading, and table.checkout_latest() returns to the newest.

solid answer

~40 s

Lance datasets are append-only at the storage layer: each operation writes new data or deletion files and commits a new manifest, so version N+1 exists alongside N rather than replacing it. `table.version` reports where the table object currently sits, `table.list_versions()` returns the versions with their timestamps, and `table.checkout(3)` pins the object to version 3 — every subsequent read, including vector search, sees the table exactly as it was then. `table.checkout_latest()` moves back to the head. While checked out to an old version, the table is read-only; `table.restore()` commits that older state as a *new* latest version, which is how you roll back without losing history. The cost is storage: old versions keep their files alive until you prune them, and pruning is what breaks time travel beyond the retention window.

go deeper

for a junior

Know that LanceDB keeps a history of table versions, that each write adds one, and that checkout(n) lets you read the table as it was at version n.

for a middle

Explain the mechanism — immutable manifests committed per write, data files shared across versions — and name list_versions, checkout, checkout_latest and restore with what each one does.

for a senior

Show the operational side: rollback via checkout plus restore, pinning versions for reproducible evaluations, and the storage growth that forces a retention and compaction policy.

for a principal

Own retention as a policy question — how long history must survive for rollback, audit and reproducibility, what that costs on object storage, and where version pinning belongs in the pipeline's configuration.

## Why versions exist at all The Lance format never edits data files in place. An append writes new fragments; an update rewrites the affected fragments; a delete writes a deletion file marking rows as removed. Each of those operations finishes by committing a new manifest — a small metadata file naming the schema, the fragments and the deletion files that make up the dataset at that instant. The manifest is the version. Because manifests are cheap and data files are shared between them, keeping history costs metadata, not a full copy of the table. This is the same idea as a copy-on-write table format in the analytics world, and it is why LanceDB gets versioning "for free": it is not a bolt-on feature, it is how writes commit. ## The API - `table.version` — the version the table object is currently reading. - `table.list_versions()` — the version history, each entry carrying a version number and a timestamp, which is how you translate "what did this look like on Tuesday" into a number. - `table.checkout(n)` — pin this table object to version n. Reads, filters and vector searches now run against that snapshot. - `table.checkout_latest()` — jump back to the newest committed version. - `table.restore()` — take the currently checked-out version and commit it as a new latest version. A table pinned to an old version is read-only; you cannot append to the past. `restore()` is the escape hatch: it makes the old state current *by moving forward*, so the history that recorded the bad write is still there. Rolling back never destroys evidence. ## What actually creates a version Everything that commits: `add()`, `delete()`, `update()`, `merge_insert(...).execute()`, `create_table(..., mode="overwrite")`, schema changes such as adding a column, and index creation. A read never does. This surprises people who assume delete is a maintenance operation outside history — deleting a million rows produces a new version whose predecessor still references every one of those rows' data files. ## What it buys you **Reproducibility.** A retrieval evaluation run against version 42 can be re-run months later against version 42 and give the same results, even though ingestion has continued. Pinning the version in the experiment's config is the whole mechanism. **Cheap rollback.** A bad embedding backfill is undone by `checkout` to the last good version, sanity-checking the data, then `restore()` — seconds of metadata work rather than restoring a backup. **Debugging.** "When did this row change?" is answerable by checking out successive versions and comparing, instead of guessing from application logs. **Consistent readers.** Because a table object reads one version, a long analytical pass is not disturbed by concurrent appends; it sees a stable snapshot until it explicitly refreshes. ## What it costs Storage, and unbounded storage if ignored. Old versions hold references to fragments and deletion files, so nothing is reclaimed while a version referencing it survives. A table under frequent small writes accumulates both versions and small fragments. `table.optimize()` is the maintenance path: it compacts data files and can prune versions older than an age you pass. Pruning is destructive to history — after it, `checkout` on a pruned version fails — so retention is a real decision: long enough to cover your rollback and reproducibility needs, short enough that the bucket does not grow forever. ## Multi-process behaviour Since each table object reads a specific version, a reader opened before a writer's commit keeps returning the older data until it refreshes. That is not staleness caused by a bug; it is the snapshot semantics working. `checkout_latest()` refreshes on demand, and `read_consistency_interval` on `connect` makes the refresh automatic on a cadence. ## How to answer Lead with the mechanism — immutable manifests committed per write — then the three calls (`list_versions`, `checkout`, `restore`), then the tradeoff: history is nearly free to keep and expensive to keep forever, so `optimize` and a retention policy are part of running a LanceDB table, not an afterthought.

  • You pinned a table to version 12 and now want that to be the live state. What do you call?
    `table.restore()` while checked out to version 12. It commits version 12's contents as a new latest version rather than deleting versions 13 and up, so the history of the bad writes remains readable and the rollback is itself an auditable event. `checkout_latest()` would do the opposite — abandon the pin and return you to the newest version.
  • Does deleting rows shrink the table on disk right away?
    No. A delete commits a new version that records the rows as removed; earlier versions still reference the data files holding them, so nothing is reclaimed. Space comes back only when compaction rewrites the fragments without the deleted rows and the versions referencing the old files are pruned — that is what `table.optimize()` is for, with an age threshold you choose.
  • How do you make time travel reproducible for an offline evaluation?
    Record the version number, not the timestamp, in the experiment's configuration, and have the job call `table.checkout(n)` before querying. `list_versions()` gives you the number and its timestamp when you set the experiment up. The one caveat is retention: if version pruning has removed that version, checkout fails, so the retention window has to outlive the experiments you intend to reproduce.

saying these in an interview costs you the question

  • Believing updates and deletes mutate data files in place
  • Thinking checkout lets you write into an old version
  • Assuming old versions are garbage collected automatically
  • Confusing restore with deleting the newer versions
  • Expecting deletes to free disk space immediately

context