Search Tech Journey

Find topics, journeys and posts

6-month learning plan52 / 130
back to blog
data engineeringintermediate 55m read

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.

🗄️Data EngineeringM05 · Data Engineering· Session 052 of 130 90 min

🎯 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.

You will be able to
  • 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

A lakehouse is a ledger stapled to a pile of receipts
🌍 Real world

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.

💻 Code world

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

Why the two-tier lake+warehouse stack died
  • 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

  1. 2006
    Hadoop / HDFS
    Cheap distributed storage. First-generation ‘data lake’ — files only, no transactions.
  2. 2013
    Parquet released
    Columnar file format. Compresses well, scans fast, becomes the lake's lingua franca.
  3. 2017
    Hudi (Uber) open-sourced
    First transactional layer on files — built for CDC and upserts at Uber's ride-events scale.
  4. 2018
    Iceberg (Netflix) open-sourced
    Snapshot-based table spec designed to fix Hive-partition pain at Netflix.
  5. 2019
    Delta Lake (Databricks) open-sourced
    JSON transaction log + protocol. Later donated to Linux Foundation.
  6. 2020
    ‘Lakehouse’ paper (CIDR)
    Armbrust, Ghodsi, Zaharia formalise the term. Snowflake pivots to Iceberg soon after.
  7. 2024
    Databricks 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

11
Write data files

Writer streams new Parquets to s3://warehouse/orders/part-*.parquet. They are invisible — no log entry yet.

22
Build add / remove actions

JSON list: which files were added, which are logically removed. Includes stats (min/max/null count per column).

33
Atomic commit

PUT _delta_log/000...N.json with if-none-match. If a concurrent writer already claimed N, retry with N+1.

44
Reader sees new snapshot

Next reader listing _delta_log/ finds the new commit and picks up the changed file set.

55
Periodic checkpoint

Every 10 commits, materialise a Parquet checkpoint so replay stays fast even after 100k commits.

Three formats, one idea

Delta Lake

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)
Apache Iceberg

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
Apache Hudi

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

SQL / DataFrame API
`df.write.format('delta').mode('append').save(path)` — the surface you type against.
engine
Query planner
Spark / Trino compiles to a physical plan and decides file layout, partition writes, and stats collection.
planner
Parquet writer
Serialises row groups, computes column min/max/null stats, writes to object storage as new immutable files.
data
Table format layer
Delta / Iceberg / Hudi library builds add/remove actions with the freshly written file paths and stats.
meta
Catalog / atomic commit
S3 conditional PUT (Delta), Glue/Nessie compare-and-swap (Iceberg), or timeline instant (Hudi). This is the ACID moment.
commit
Downstream reader
Any subsequent SELECT reads the log first, sees the new snapshot, scans only the live files.
read

Common misconception
✗ What most people think

"A lakehouse table format like Delta or Iceberg is a new file format. It's a faster alternative to Parquet."

✓ What is actually true

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".

Why the myth is so sticky

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.

Prove it to yourself

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.

From first principles
Start with the question

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.

  1. 1
    A "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
  2. 2
    Object 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
  3. 3
    Therefore 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
  4. 4
    Fixing 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
  5. 5
    Making 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

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.

Mental modelGit for tables

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.
🔔 Fires when you see

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.

The tradeoff

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)?

Copy-on-write
+ you gain readers are as fast as plain Parquet — no reconciliation work, no extra files to consult, and the physical layout stays clean. Downstream consumers and external engines need no special support.
− you pay write amplification. Changing a handful of rows rewrites every file containing them, so a small logical change becomes a large physical write. Latency of a MERGE scales with the size of the touched files, not the size of the change.
pick when read-heavy tables with batch updates, where changes are concentrated in recent partitions and read latency has an SLA
Merge-on-read (deletion vectors / delete files)
+ you gain writes become cheap and roughly proportional to the change size, which makes frequent small updates and CDC ingestion viable at low latency.
− you pay every read must apply the deletes on top of the base files, so read cost grows with the number of pending delete files. Left unmaintained, read performance degrades continuously, and older external readers may not understand the delete encoding at all.
pick when write-heavy or streaming CDC tables where update frequency is high and you have a scheduled compaction job that actually runs
Append-only + downstream dedup
+ you gain the simplest and cheapest possible write path: no merge, no conflicts, no delete files. Full history is retained by construction, which is a compliance and debugging asset.
− you pay storage grows without bound and every consumer must implement the same "latest row per key" window function — logic duplicated across teams, which is where the semantics eventually diverge.
pick when event-shaped data where history has independent value, and current state is served from a separate curated table you own
What a senior engineer actually does

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.py

You 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

reset()
The whole table is a folder. `rm -rf` gives you a clean slate — no database server to drop.
files
write_deltalake(mode='overwrite')
First commit. Creates `_delta_log/00000...0.json` with `protocol`, `metaData`, and `add` actions.
commit-0
mode='append'
New Parquet files land under `day=tue/`. Log gains `00000...1.json` with only `add` actions — no data rewritten.
commit-1
dt.delete(predicate=...)
Reads the matching files, rewrites them without matching rows, and commits `remove` + `add` actions. Old files still exist on disk until `vacuum()`.
commit-2
DeltaTable(..., version=N)
Time travel. The reader replays the log up to commit N and only sees files live at that point.
time-travel
peek_transaction_log()
The ‘magic’ demystified — the log is just JSONL. Every ACID guarantee flows from those files.
log
Try itBreak time travel on purpose to feel VACUUM

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 FileNotFoundError

Re-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.

💡 Hint · After running vacuum with retention_hours=0, try `DeltaTable(str(TABLE), version=0).to_pandas()`. It'll raise FileNotFoundError. That's why VACUUM has a default 7-day retention in production.

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

War story Netflix· 2018petabytes across 100k+ Hive tables
🔥 What broke

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.

🧯 The fix
Ryan Blue's team built Apache Iceberg. Every commit produces a new immutable manifest tree; the current table is a single pointer in a catalog. Atomic swap on that pointer = ACID. No LIST calls, no rename races. Netflix migrated all core tables by 2020 and open-sourced the spec.
🎓 Lesson to steal
Hive-style ‘directory = partition’ dies at cloud scale. The lakehouse formats all replaced LIST-and-hope with an explicit metadata pointer. This is the single biggest reason Iceberg exists.
Post-mortem
War story Uber· 2016ride events + trip updates, minute-latency SLA
🔥 What broke
Uber's trip data landed as an hourly batch. A rider tipping 20 minutes after the ride ended meant the row had to be updated — but HDFS + Parquet had no UPDATE. Every ‘update’ became a full-partition rewrite, costing hours of Spark time per day.
🧯 The fix
Vinoth Chandar built Apache Hudi. Merge-on-Read tables append delta log files next to base Parquets; readers merge on the fly, and a background compactor periodically folds deltas into new base files. Update latency went from hours to minutes.
🎓 Lesson to steal
If your workload is upsert-heavy (CDC, event correction, GDPR delete), don't pick Delta/Iceberg blindly. Hudi's MoR mode was designed for exactly this and still wins on write throughput.
Post-mortem
War story Databricks customers (repeat pattern)the ‘small files’ tax, seen at every Delta shop
🔥 What broke
A streaming job wrote one 5 MB Parquet file per micro-batch — 12 files a minute, 17,000 a day. After a month the table had 500k tiny files. Query planning alone took 4 minutes because Spark had to open every file's footer for stats.
🧯 The fix
Nightly 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.
🎓 Lesson to steal
Lakehouse formats give you ACID, but the file-layout hygiene is on you. Budget OPTIMIZE + VACUUM as first-class recurring jobs from day one, not "we'll get to it".
Post-mortem

Where this shows up in the rest of the plan

The lakehouse is the substrate for modern data platforms
S049 · Spark
Reference writer/reader for Delta and Iceberg — the JVM API you'll see in every job description.
S053 · Data quality
Freshness/volume checks read the same transaction log to detect drops or late arrivals.
S054 · Governance & cost
Lineage tooling (OpenLineage, Unity Catalog) hooks into commit events emitted by Delta/Iceberg.
S089 · Feature store
Offline feature stores (Feast, Tecton) sit on Delta/Iceberg tables with point-in-time correct joins.
S116 · Real-time analytics
Hudi MoR + Flink is the reference stack for minute-latency analytics on the lake.
S128 · Data platform architecture
Every capstone architecture in this series assumes a lakehouse as the ‘source of truth’ layer.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

You can move on if you can teach these three without notes:

  1. What is the transaction log and why is it the whole magic? (one sentence, no jargon)
  2. When would you pick Iceberg over Delta, and vice versa? (two concrete workloads)
  3. 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.