Module 3: Snapshots And Time Travel

Time travel: AS OF a snapshot-id

Description

Everything earlier in this module built toward this moment. You have snap_v1 captured (lesson 2), you know why you never hardcode it (lesson 4), and you know the current table has shown V2 since lesson 3. This lesson finally makes the trip through time: table.scan(snapshot_id=snap_v1), a single line of code that asks Iceberg "don't show me the current state — show me exactly what this table looked like at that specific snapshot."

Connection to the module. This lesson is the hinge between "having the theory" and "having the result." Lessons 2 through 4 prepared everything needed; this lesson uses it. Lesson 6 is going to take what you recover here and connect it to the JOIN against fact_orders to reproduce P002's correct margin — this module's full payoff.

An analogy: asking the archive for a specific day's photo

Come back, one last time in this module, to the supermarket photo archive's keeper. So far, every time someone asks "how's the shelf?", the keeper shows the most recent photo — it's what they do by default, without anyone explicitly asking. But the whole archive is still there, photo by photo, each one filed alongside its roll number. This lesson is the moment to ask the keeper, precisely, "not today's photo — give me the photo from that specific roll, the one from before the last restock." The keeper doesn't have to reconstruct anything, doesn't have to guess, doesn't have to consult any record other than date columns designed for this — they simply go to the archive, pull the photo with that number, and hand it to you exactly as it looked that day.

Worked example: the trip through time, side by side with the present

Step 1 — The current state, no time travel

# time_travel.py
import os

from pyiceberg.catalog import load_catalog

warehouse_path = os.path.abspath("kiosko_warehouse")
catalog_db_path = os.path.abspath("kiosko_catalog.db")

catalog = load_catalog(
    "kiosko", type="sql",
    uri=f"sqlite:///{catalog_db_path}", warehouse=f"file://{warehouse_path}",
)
table = catalog.load_table("kiosko.dim_product")

print("=== table.scan() -- NO time travel, the current state ===")
for row in table.scan().to_arrow().to_pylist():
    print(f"  {row['product_id']}  {row['category']:<14} unit_cost={row['unit_cost']}")

What to expect (verified by running the actual script):

=== table.scan() -- NO time travel, the current state ===
  P001  beverages      unit_cost=0.4
  P002  health-snacks  unit_cost=0.68
  P003  beverages      unit_cost=0.35
  P004  electronics    unit_cost=2.1

This is exactly what lesson 3 left as current — nothing new yet.

Step 2 — The same scan, AS OF snap_v1

# snap_v1 is recovered exactly the way you captured it in lesson 2
# (or the way you recovered it in lesson 4, if you lost it). Here, so
# this script is self-contained, it's recovered by business content:
history = table.history()
snap_v1 = next(
    entry.snapshot_id
    for entry in history
    if any(
        r["product_id"] == "P002" and r["category"] == "snacks"
        for r in table.scan(snapshot_id=entry.snapshot_id).to_arrow().to_pylist()
    )
)

print("\n=== table.scan(snapshot_id=snap_v1) -- TIME TRAVEL ===")
for row in table.scan(snapshot_id=snap_v1).to_arrow().to_pylist():
    print(f"  {row['product_id']}  {row['category']:<14} unit_cost={row['unit_cost']}")

What to expect (verified by running the actual script; snap_v1 is shown recovered by content, not as a number — see this module's lesson 4):

=== table.scan(snapshot_id=snap_v1) -- TIME TRAVEL ===
  P001  beverages  unit_cost=0.4
  P002  snacks     unit_cost=0.6
  P003  beverages  unit_cost=0.35
  P004  electronics unit_cost=2.1

Notice what changed, and what didn't. P002 went back to category='snacks', unit_cost=0.6 — exactly the V1 value lesson 2 loaded. P001, P003, and P004 are identical in both scans — makes sense: those three products never changed between V1 and V2, so it makes no difference whether you look at the earlier snapshot or the current one. The only parameter that changed between this lesson's two blocks of code is snapshot_id=snap_v1 — not a single line of your schema, your JOIN, or your business logic had to change.

Step 3 — Confirm the grain didn't change: four rows in both versions

current_count = table.scan().to_arrow().num_rows
v1_count = table.scan(snapshot_id=snap_v1).to_arrow().num_rows
print(f"\nrows in the current state: {current_count}")
print(f"rows AS OF snap_v1: {v1_count}")

What to expect:

rows in the current state: 4
rows AS OF snap_v1: 4

Four rows in both versions — time travel didn't lose or add a single row, it only changed which values you see for those four rows. This confirms something important: table.scan(snapshot_id=...) isn't a partial or approximate operation — it's exactly the same kind of scan() you already know, with the same to_arrow(), the same ability to apply row_filter or selected_fields if you need them — the only difference is which snapshot it uses as its source.

Diagram: one parameter, two realities

flowchart TB
    T["kiosko.dim_product"]
    T -->|"table.scan()\n(no arguments)"| C["reads the CURRENT snapshot\nP002 = health-snacks / 0.68"]
    T -->|"table.scan(snapshot_id=snap_v1)"| P["reads the snap_v1 snapshot\nP002 = snacks / 0.60"]

    C -.->|"current-snapshot-id\nfrom the current metadata.json"| M1["current metadata file\n(lesson 3, module 2)"]
    P -.->|"explicit snap_v1,\nignores current-snapshot-id"| M2["the snap_v1 snapshot,\nfiled away, never deleted"]

Going deeper: why time travel is always read-only

It's worth being explicit about something this lesson's example demonstrates but doesn't say out loud: table.scan(snapshot_id=snap_v1) doesn't modify anything. It doesn't move the catalog's current-snapshot-id pointer, doesn't create a new snapshot, doesn't "restore" the table to that state. It is, with full precision, a read — the same class of operation as a SQL SELECT, just pointing at a specific point in the history instead of the most recent one. You can run table.scan(snapshot_id=snap_v1) as many times as you want, in any order, mixed with reads of the current state, with no side effect at all on the table or on any other query. This property — that traveling through time never changes time — is what makes time travel safe for audits, debugging historical reports, or simple curiosity, with no risk of anyone accidentally "reverting" the table to an old state with a read query.

This also answers a question that might have come up from lesson 4: if you need to travel through time by date instead of by snapshot_id, PyIceberg offers table.snapshot_as_of_timestamp(timestamp_ms, inclusive=True), which returns the Snapshot object current at that instant — you capture its .snapshot_id and pass it to table.scan(snapshot_id=...), the exact same method you used in this lesson. And for anyone working with Spark SQL over Iceberg — this guide's module 6 — the equivalent syntax is SELECT * FROM local.kiosko.dim_product VERSION AS OF <snapshot_id> or TIMESTAMP AS OF <date>: same concept, two different surfaces — Python and SQL — over the same snapshot mechanism.

Common mistakes

Expecting table.scan(snapshot_id=snap_v1) to change what table.scan() with no arguments returns. What happens: someone runs this lesson's step 2, sees it recovers P002=snacks, and then runs step 1 again — table.scan() with no arguments — expecting to also see snacks, because "I just traveled through time." Why it happens: it's easy to confuse "querying a past state" with "restoring that state," especially coming from systems where a rollback really does change the current state. How to spot it: if after running a table.scan(snapshot_id=...) you expect the table's default behavior to have changed, revisit this lesson's Going deeper section. How to fix it: table.scan(snapshot_id=snap_v1) is read-only — the table's current snapshot is the same before and after that call. If you genuinely needed to revert a table's current state to an earlier snapshot — a real operation, different from read-only time travel — that's an explicit administration operation (table.manage_snapshots()), not something that happens as a side effect of a read.

Trying to pass a snapshot_id from a different table. What happens: someone, working with more than one table in the same script — kiosko.dim_product and kiosko.fact_orders, for example — mixes up which snapshot_id belongs to which table, and passes one to the other's scan(). Why it happens: snapshot_ids are large integers with no prefix or visual hint about which table they belong to, so it's easy to mix them up if you have several loose variables in the same script. How to spot it: if table.scan(snapshot_id=something).to_arrow() fails with an error stating that snapshot doesn't exist for this table, check which table you originally captured that snapshot_id from. How to fix it: name your variables with the table included when working with more than one at a time — for example, dim_product_snap_v1 instead of just snap_v1 — so they can't be mixed up by accident.

Exercises

Exercise 1 — Reproduce the trip through time yourself, and confirm four rows in both versions. With lessons 2 and 3's state available, run this lesson's three steps. Confirm P002 changes between snacks/0.6 and health-snacks/0.68 depending on which scan() you use, and that the other three rows are identical in both cases.

See solution

Your output should exactly match this lesson's: four rows in each scan, with P002 as the only row that differs between the current state and snap_v1. If P001, P003, or P004 also differ between both scans, check whether you loaded V1 and V2 exactly as lessons 2 and 3 defined them — those three products should never change in this module.

Exercise 2 — Use table.snapshot_as_of_timestamp() instead of an explicit snapshot_id. Using history[0].timestamp_ms — the timestamp of dim_product's first snapshot — call table.snapshot_as_of_timestamp(history[0].timestamp_ms, inclusive=True) and confirm the .snapshot_id it returns matches the snap_v1 you recovered in step 2 of this lesson.

See solution
history = table.history()
snap_by_timestamp = table.snapshot_as_of_timestamp(history[0].timestamp_ms, inclusive=True)
print("matches snap_v1:", snap_by_timestamp.snapshot_id == snap_v1)

The result should be Truehistory[0] is the first snapshot chronologically, and its timestamp_ms is, by definition, the exact moment it was created. Asking snapshot_as_of_timestamp() for the snapshot current at that exact instant (with inclusive=True, which includes the snapshot created right at that millisecond) should return that same snapshot. This exercise confirms time travel by date and time travel by snapshot_id are, deep down, two roads to the same destination.

Exercise 3 — Prediction: what would table.scan(snapshot_id=snap_v1, row_filter="product_id == 'P002'") return? Without running it, predict: if you combine V1's snapshot_id with a row_filter that only asks for P002, how many rows and with what values do you expect to see? Justify your answer by thinking about row_filter and snapshot_id being two independent parameters of the same scan().

See solution

A single row: P002, category='snacks', unit_cost=0.6. snapshot_id decides which version of the table you read from; row_filter decides which rows from that version you care about — they're two independent filters, applied together, not one replacing the other. This is exactly the same scan() you already know from module 1, with two parameters combined instead of just one — time travel doesn't create a parallel API, it integrates into the one you were already using.

Summary and next step

In this lesson you traveled through time for real: table.scan(snapshot_id=snap_v1) recovered kiosko.dim_product's exact state before the P002 change — snacks/0.6 — while table.scan() with no arguments still shows the current state — health-snacks/0.68. You confirmed the grain doesn't change between versions (four rows in both), and that time travel is always a read-only operation, with no effect at all on the table's current state.

Before moving on you should be able to: use table.scan(snapshot_id=...) to read a historical state of any Iceberg table; explain why time travel never modifies the table; and name the date-based alternative (snapshot_as_of_timestamp) for when you don't have a snapshot_id at hand but do know the approximate date.

You have P002's correct state recovered. Lesson 6 connects that result with kiosko.fact_orders to finally calculate P002's correct margin — the same 10.8 you already saw in data-modeling and dbt — using a table with no history column at all.

Resources

  • PyIceberg — API reference, the exact syntax of table.scan(snapshot_id=...). py.iceberg.apache.org/api. In English.
  • Apache Iceberg — official documentation, "Spark Queries," the equivalent VERSION AS OF/TIMESTAMP AS OF syntax in SQL, revisited in this guide's module 6. iceberg.apache.org/docs/latest/spark-queries. In English.
  • data-modeling-for-analytics-guide DESIGN doc — source of P002's exact V1 state (snacks/0.60) this time travel recovers. src/guides/data-modeling-for-analytics-guide/DISENO.md. In Spanish.
  • This guide's DESIGN doc — the "Snapshots and time travel" section (M3), the exact source of this step in the experiment. src/guides/lakehouse-and-iceberg-guide/DISENO.md. In Spanish.