S052 · Lakehouse — Delta / Iceberg / Hudi, ACID on Files
How Parquet-on-object-storage grew a transaction log and killed the data warehouse monopoly. Delta, Iceberg, and Hudi compared with real code, real failure modes, and the three-line commit that changed data engineering.
🎯 Read and write a Delta table with time travel, then explain why lakehouses replaced ‘lake + warehouse’ architectures at every major data platform since 2020.
Why this session exists
For a decade, "big data" meant a two-tier stack: cheap object storage (S3, ADLS, GCS) for raw files, and an expensive warehouse (Snowflake, BigQuery, Redshift) copied on top for SQL. Every table lived twice, cost twice, and drifted the moment a batch failed halfway through. The lakehouse collapsed both tiers by bolting a transaction log onto plain Parquet files. Today Databricks, Snowflake, BigQuery, Netflix, Apple, Airbnb, and Uber all ship one — you will encounter Delta, Iceberg, or Hudi on your first data-platform job.
- Explain ACID-on-object-storage in 60 seconds using the words ‘manifest’, ‘snapshot’, and ‘atomic swap’.
- Read + write a Delta table locally with delta-rs and do a `VERSION AS OF 3` time-travel query.
- Contrast Delta / Iceberg / Hudi on three axes: engine coupling, catalog model, write pattern.
- Diagnose the top three lakehouse failure modes: small files, missing compaction, and metadata bloat.
- Choose the right format for a given workload (streaming ingest, BI, ML feature store) with a defensible reason.
Prerequisites
- S049 · Batch processing with Spark — you already know what a Parquet file is.
- S050 · Streaming basics — Kafka + micro-batch mental model helps for Hudi.
- S051 · Warehouse vs lake — you know why the two-tier architecture existed in the first place.
(a) Intuition · 5 min
Imagine you run a warehouse of physical inventory. The boxes are the truth — but you can't find anything without a ledger at the front desk that says "Box #47 contains rows 1000-2000 of the orders table, added Tuesday, superseded by Box #52 on Thursday".
Any time you add or remove a box, you write one atomic line in the ledger. Two people writing at once? The ledger has a lock. Want to see the warehouse as it existed last Wednesday? Read the ledger up to that timestamp and ignore later entries.
A lakehouse is exactly this. The boxes are immutable Parquet files in S3. The ledger is a JSON transaction log in _delta_log/ or a metadata tree in Iceberg. Every insert, update, or delete appends one commit describing which files were added and removed.
Readers open the log first, resolve "which files are live right now?", and read only those. Writers append a new commit via an atomic PUT (S3's if-none-match, or a catalog compare-and-swap). ACID on plain files, no database server required.
The three problems the lakehouse solves
- Consistency — a job that half-writes 200 Parquet files used to leave the table in a broken state. The transaction log makes the commit atomic: either all 200 files become visible, or none do.
- Time travel — every commit is retained. `SELECT * FROM orders VERSION AS OF 42` is a normal query, not a heroic backup restore. Ideal for reproducible ML training and audit.
- Schema + upserts on cheap storage — MERGE INTO, DELETE, UPDATE now work directly on S3 Parquet. You no longer need to copy 5 TB into Snowflake just to run one UPDATE.
How we got here
- 2006Hadoop / HDFSCheap distributed storage. First-generation ‘data lake’ — files only, no transactions.
- 2013Parquet releasedColumnar file format. Compresses well, scans fast, becomes the lake's lingua franca.
- 2017Hudi (Uber) open-sourcedFirst transactional layer on files — built for CDC and upserts at Uber's ride-events scale.
- 2018Iceberg (Netflix) open-sourcedSnapshot-based table spec designed to fix Hive-partition pain at Netflix.
- 2019Delta Lake (Databricks) open-sourcedJSON transaction log + protocol. Later donated to Linux Foundation.
- 2020‘Lakehouse’ paper (CIDR)Armbrust, Ghodsi, Zaharia formalise the term. Snowflake pivots to Iceberg soon after.
- 2024Databricks acquires Tabular$2B for the Iceberg team; Delta/Iceberg convergence roadmap begins.
(b) Visual walkthrough · 15 min
Anatomy of a Delta table on S3
Every reader begins by listing _delta_log/, replaying the JSONs (or jumping to the latest .checkpoint.parquet + tail), and computing the live file set. Then it reads only those Parquets.
The commit lifecycle
Writer streams new Parquets to s3://warehouse/orders/part-*.parquet. They are invisible — no log entry yet.
JSON list: which files were added, which are logically removed. Includes stats (min/max/null count per column).
PUT _delta_log/000...N.json with if-none-match. If a concurrent writer already claimed N, retry with N+1.
Next reader listing _delta_log/ finds the new commit and picks up the changed file set.
Every 10 commits, materialise a Parquet checkpoint so replay stays fast even after 100k commits.
Three formats, one idea
JSON log + checkpoints · Databricks origin
- Metadata: JSON files in `_delta_log/`
- Engine gravity: Spark-first, but delta-rs (Rust) unlocks Python / Trino / DuckDB
- Write pattern: batch + streaming append, MERGE INTO
- Sweet spot: Databricks / Spark shops, unified batch+streaming
- Weakness: catalog story is loose (path-based by default)
Metadata tree + catalog · Netflix origin
- Metadata: manifest lists + manifest files (Avro), pointer in a catalog
- Engine gravity: engine-agnostic — Trino, Spark, Flink, Snowflake, BigQuery all read/write
- Write pattern: snapshot-per-commit; branches + tags (Git-style)
- Sweet spot: multi-engine warehouses, open catalogs (Nessie, Polaris, Unity)
- Weakness: more moving parts; catalog is mandatory
Timeline + record-level indexes · Uber origin
- Metadata: timeline of instant files + optional record index
- Engine gravity: Spark + Flink first; strong streaming-CDC support
- Write pattern: two modes — Copy-on-Write (BI) and Merge-on-Read (streaming upsert)
- Sweet spot: high-frequency upserts, CDC ingestion, real-time lakes
- Weakness: steeper mental model; MoR needs compaction discipline
Layers of a lakehouse write
What actually happens when you write one row
"A lakehouse table format like Delta or Iceberg is a new file format. It's a faster alternative to Parquet."
Delta and Iceberg store their data in Parquet. They are metadata layers: an ordered log of which files constitute the table at each version. The bytes on disk are the same columnar Parquet you already had — what's new is a transaction log that turns "a directory of files" into "a table with snapshots".
The myth is sticky because you interact with them the way you interact with a format: spark.read.format("delta") sits exactly where "parquet" used to. And the performance does improve, so "it's a faster format" explains your observation. But the speedup comes from file skipping via per-file column statistics in the metadata, not from any change to the encoding of a single file.
Look inside a Delta table and see that it is Parquet plus a log:
ls -R /path/to/delta_table
# part-00000-....snappy.parquet <- ordinary Parquet
# _delta_log/00000000000000000000.json
# _delta_log/00000000000000000001.json
cat /path/to/delta_table/_delta_log/*.json | head -40
# {"add":{"path":"part-000...","size":...,"stats":"{...min/max...}"}}
# {"remove":{"path":"part-000...",...}}Every "update" is an add of new files and a remove of old ones. Nothing is edited in place, because object storage cannot edit in place.
Why do lakehouse formats need a transaction log at all? S3 and ADLS are already durable and highly available. This looks like ceremony — it isn't.
- 1A "table" on object storage was historically defined as "every file under this prefix". The reader discovers membership by listing the directory.forced by · there is no catalog of file membership; the filesystem path IS the definition
- 2Object stores give you atomicity per object, but no atomicity across objects. Writing 200 Parquet files is 200 independent operations.forced by · each PUT is an independent HTTP request to a different shard of a distributed store
- 3Therefore a reader listing the prefix mid-write sees a partial set of files — a half-written table that is internally inconsistent and yet raises no error.forced by · listing succeeds happily; there is nothing marking the write as incomplete
- 4Fixing this requires making table membership a single atomic fact rather than an emergent property of the directory. So you write one small file that names the members, and readers consult that instead of listing.forced by · you can only get cross-file atomicity by reducing the commit to a single-object operation, which the store does guarantee
- 5Making that pointer file append-only and monotonically numbered turns concurrent writers into a compare-and-swap race: whoever creates version N+1 first wins, the loser retries against the new state.forced by · optimistic concurrency needs exactly one linearizable point, and creating the next log file is it
Therefore the log is not bookkeeping — it is the only mechanism by which a multi-file write on object storage can be made atomic. ACID on a lake is achieved by moving the commit point from "all the data files" to "one tiny metadata file".
And note what this predicts: time travel comes for free. If the log is an append-only sequence of "which files are members at version N", then reading an old version is just reading an old log entry — no extra machinery required. It also predicts that you can never delete old data files eagerly: VACUUM must have a retention window, because those files are still the content of older snapshots. That is exactly why vacuuming below the retention threshold requires overriding a safety check.
Immutable data files are blobs. The transaction log is the commit history. A snapshot/version is a commit — a manifest naming exactly which blobs are live. Writers commit optimistically and retry on conflict, exactly like a push that gets rejected because the remote moved.
Nothing is ever modified in place. An UPDATE is: read the affected files, write new files with the change applied, then commit a log entry that removes the old ones and adds the new ones.
- Every write creates new files. Row-level updates are therefore file-level rewrites — a one-row update to a 512 MB file rewrites 512 MB unless the format supports deletion vectors / merge-on-read.
- Reads are snapshot-isolated by construction: a reader pins a version at start and is immune to concurrent writes. No read locks exist because none are needed.
- File skipping is the whole performance story. Min/max stats per column per file in the metadata let the planner prune files before any I/O — which is why the physical clustering of your data (Z-order, sort order, partitioning) determines query cost far more than the engine does.
- Small files are the tax. Streaming writes produce thousands of tiny files, metadata grows, planning slows, and skipping degrades. Compaction (
OPTIMIZE/ rewrite manifests) is not optional maintenance — it is part of the operating model.
Fire this model when you see: readers seeing partial data from a concurrent job · a query getting slower over weeks with no logic change · "concurrent append exception" retries · someone asking to restore yesterday's table · a MERGE that takes hours on a small changeset.
Your table receives frequent small updates and deletes. Do you rewrite whole data files on every change (copy-on-write) or record the changes separately and reconcile at read time (merge-on-read)?
Default to copy-on-write. It is the boring option and its cost is visible: a slow MERGE shows up in your job runtime immediately. Merge-on-read moves the cost to readers, where it is diffuse, attributed to other teams' queries, and easy to ignore until it is a crisis.
Switch to merge-on-read only when write latency is genuinely the binding constraint, and treat compaction as part of the pipeline — scheduled, monitored, and alerted on — not as maintenance you will get to later. The governing rule: whichever side you optimise, the other side accumulates debt, so instrument both file count and average file size as first-class table metrics.
(c) Hands-on · 25 min
We'll build a Delta table locally with delta-rs (no Spark, no JVM). This is the same library that powers Polars, Daft, and dbt-duckdb integrations — the fastest way to see the transaction log with your own eyes.
"""
lakehouse_demo.py — Create a Delta table, evolve it, time-travel through history.
Requires: pip install "deltalake>=0.17" pandas pyarrow
Runs entirely on your laptop — no Spark, no S3.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pandas as pd
from deltalake import DeltaTable, write_deltalake
TABLE = Path("/tmp/lakehouse_demo/orders")
def reset() -> None:
"""Start from a clean slate so the demo is idempotent."""
if TABLE.exists():
shutil.rmtree(TABLE)
TABLE.parent.mkdir(parents=True, exist_ok=True)
def commit_0_initial_load() -> None:
"""Commit 0 — three orders on Monday."""
df = pd.DataFrame(
[
{"order_id": 1, "customer": "alice", "amount": 42.00, "day": "mon"},
{"order_id": 2, "customer": "bob", "amount": 19.50, "day": "mon"},
{"order_id": 3, "customer": "carol", "amount": 88.00, "day": "mon"},
]
)
write_deltalake(str(TABLE), df, mode="overwrite", partition_by=["day"])
print(f"[commit 0] wrote {len(df)} rows")
def commit_1_append_tuesday() -> None:
"""Commit 1 — two more orders on Tuesday."""
df = pd.DataFrame(
[
{"order_id": 4, "customer": "dave", "amount": 12.00, "day": "tue"},
{"order_id": 5, "customer": "eve", "amount": 66.75, "day": "tue"},
]
)
write_deltalake(str(TABLE), df, mode="append", partition_by=["day"])
print(f"[commit 1] appended {len(df)} rows")
def commit_2_delete_customer(target: str) -> None:
"""Commit 2 — GDPR-style delete: remove everything for `target`."""
dt = DeltaTable(str(TABLE))
deleted = dt.delete(predicate=f"customer = '{target}'")
# delete() returns operation metrics as a dict
print(f"[commit 2] deleted {deleted['num_deleted_rows']} rows for {target}")
def show_history() -> None:
dt = DeltaTable(str(TABLE))
print("\n=== history ===")
for entry in dt.history():
print(
f"v{entry['version']:>2} {entry['operation']:<10}"
f" ts={entry.get('timestamp','?')}"
)
def time_travel(version: int) -> None:
dt = DeltaTable(str(TABLE), version=version)
df = dt.to_pandas()
print(f"\n=== SELECT * VERSION AS OF {version} ({len(df)} rows) ===")
print(df.to_string(index=False))
def peek_transaction_log() -> None:
"""Read the raw JSON commits — the whole point of ‘it's just files’."""
log_dir = TABLE / "_delta_log"
commits = sorted(log_dir.glob("*.json"))
print(f"\n=== _delta_log/ ({len(commits)} commit files) ===")
for path in commits:
actions = [json.loads(line) for line in path.read_text().splitlines()]
kinds = ", ".join(sorted({next(iter(a)) for a in actions}))
print(f" {path.name}: {len(actions)} actions [{kinds}]")
def main() -> None:
reset()
commit_0_initial_load()
commit_1_append_tuesday()
commit_2_delete_customer("bob")
show_history()
time_travel(0) # Monday-only, 3 rows
time_travel(1) # Monday+Tuesday, 5 rows
time_travel(2) # bob is gone, 4 rows
peek_transaction_log()
if __name__ == "__main__":
main()Run it:
pip install "deltalake>=0.17" pandas pyarrow
python lakehouse_demo.pyYou should see three history entries, three different row counts across the time-travel queries, and three commit files (0000...0.json, 0000...1.json, 0000...2.json) inside _delta_log/.
Anatomy of the script
What each block teaches
Add this at the bottom of main():
dt = DeltaTable(str(TABLE))
# Aggressive vacuum — deletes files not referenced by the *current* snapshot.
dt.vacuum(retention_hours=0, enforce_retention_duration=False, dry_run=False)
time_travel(0) # this will now raise FileNotFoundErrorRe-run the script. You'll see the append and delete succeed, but the time_travel(0) call throws because the physical Parquets for that snapshot were deleted. This is the eternal tradeoff: cheap storage vs infinite history.
Reading the same table from Polars (bonus)
import polars as pl
lf = pl.scan_delta("/tmp/lakehouse_demo/orders")
print(lf.filter(pl.col("amount") > 20).collect())Same table, no code change, different engine. That's the open in "open lakehouse" — the file layout is the contract, not the runtime.
(d) Production reality · 15 min
Netflix's warehouse ran on Hive tables partitioned by dateint=20180912/hour=13. Two independent Spark jobs could each rename a staging directory into the same partition and silently overwrite each other's data.
Reading was worse: a LIST on S3 for a huge table returned inconsistent results (S3 was eventually consistent then), so queries occasionally missed files that had just been written.
LIST-and-hope with an explicit metadata pointer. This is the single biggest reason Iceberg exists.OPTIMIZE orders ZORDER BY (customer_id) merged the tiny files into 128 MB chunks and clustered by the common filter key. Query planning dropped to 3 seconds; scan time dropped 8×. Then a weekly VACUUM reclaimed the space.Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
You can move on if you can teach these three without notes:
- What is the transaction log and why is it the whole magic? (one sentence, no jargon)
- When would you pick Iceberg over Delta, and vice versa? (two concrete workloads)
- What are OPTIMIZE and VACUUM, and why can't you skip them in production? (two symptoms of skipping)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.