Add a table format to your data lake — and get snapshots, time travel, and ACID for free.
Data Engineering Module 4 Modern LakehouseDuckDB queried our Parquet directly — fast, zero-config, no copy step. The same SQL worked in CLI scripts, in tests, and inside Airflow tasks.
What’s the schema of events? What if two writers append at once? What did the data look like yesterday? Plain Parquet on disk has no answers to any of these.
| File format | Table format | |
|---|---|---|
| Examples | Parquet, ORC, Avro | Iceberg, Delta Lake, Hudi |
| Stores | Bytes (one file) | A set of files + metadata + history |
| Knows the schema? | One file at a time | Across the whole table |
| Snapshots / time travel | No | Yes |
| ACID writes | No | Yes |
| Many engines read it? | Yes | Yes (decouples engine from format) |
The Iceberg pitch: keep your Parquet files, gain a metadata layer on top.
pyiceberg), Rust, and Go — engines choose their native client.warehouse/default/events/
data/
event_date=2026-05-26/
00000-0-<uuid>.parquet
00000-1-<uuid>.parquet
event_date=2026-05-27/
...
metadata/
v1.metadata.json ← table-level metadata (schemas, snapshots)
v2.metadata.json ← new version after first append
snap-<id>-1.avro ← snapshot manifest (lists data files)
<uuid>.manifest.avro ← per-file stats (min/max per column)
The data/ half is the Parquet you already know. The metadata/ half is what Iceberg adds.
Every write to an Iceberg table produces a new immutable snapshot — a pointer to the exact set of data files that constitute the table at that instant.
expire_snapshots().metadata.json.Append → new snapshot. Overwrite → new snapshot. Delete → new snapshot. The table history is a linked list of snapshots.
# Current state
arrow_curr = table.scan().to_arrow()
# State as of an earlier snapshot (pyiceberg API)
arrow_prev = table.scan(snapshot_id=12345).to_arrow()
# Or via DuckDB by pointing at an older metadata.json
# SELECT count(*) FROM iceberg_scan('warehouse/.../v3.metadata.json');
Every snapshot points to a metadata.json. Reading an older JSON reads the table as of that point — no backup tape, no restore step.
v<N>.metadata.json, composes a new v<N+1>.metadata.json referencing new data files, then asks the catalog to swap the pointer atomically.NULL for the new column.table.update_schema().add_column("commit_count", LongType()).commit()
# Rename without rewriting any data files:
table.update_schema().rename_column("actor", "actor_username").commit()
Backward-compatible by design — no file rewrite needed for any of these operations.
event_date=2026-05-26/; tomorrow’s might sit under event_date=2026-05-27/event_hour=14/ after you add an hourly transform.Catalog: SqlCatalog backed by a SQLite file.
Warehouse: a local directory.
Writer: write_from_parquet.py reads L02 Parquet → appends to the Iceberg table. One append = one snapshot.
INSTALL iceberg; LOAD iceberg;
SELECT * FROM iceberg_scan('…metadata.json');
No JVM, no Spark, no Hadoop. The whole stack fits on a laptop.
expire_snapshots() — deletes data files no longer referenced by any retained snapshot. Frees disk space. Wire into a weekly maintenance task.rewrite_data_files() — compacts many small files into fewer large ones. One Airflow run per partition produces one small Parquet file; compact periodically to keep scan latency low.Expiring snapshots is irreversible — once a snapshot is expired, its data files are subject to deletion and time travel to that point is gone. Test the retention policy on a dev table first. Default retention: at least as long as your longest backfill window.