The Delta transaction log
A Delta table is a folder of Parquet files plus an ordered log that says which of those files make up the table right now. Everything Delta can do, from ACID writes to time travel, comes from that log.
On this page
Show code in
Every code block on the page follows this.
You will learn
- What is inside
_delta_logand what each action means - How a reader rebuilds the table from commits and checkpoints
- How two writers commit at once without corrupting data
- Why VACUUM can break time travel
Read first
- Parquet and columnar storage · 4 min read
- ACID on object storage · 4 min read
Comfortable with these? Read on.
Why it exists
Picture a plain Parquet table: a folder, and "the table" is every file in it. That definition breaks in three ways.
- Half-finished writes are visible. A job writing 100 files that dies after 60 leaves 60 files behind. Readers see a partial result with no error.
- Updates are not atomic. Rewriting a file means deleting the old one and writing a new one. A reader between those two steps sees missing or duplicate rows.
- Listing is slow. Listing millions of objects in S3 or ADLS takes minutes, and engines must trust that the listing is complete.
Delta changes the definition: the table is the set of files the log says are live, not whatever happens to be in the folder.
The mental model
Think of a bank ledger. The ledger never stores your balance. It stores every deposit and withdrawal, and the balance is what you get by adding them up in order. You can also get the balance as of any past date by stopping early.
Bank ledger
Delta log
add file, remove file. Table = replay of commits. Time travel = stop at a version.Inside _delta_log
Next to the data files sits one folder. Each commit is a JSON file named by its version, padded to 20 digits. Every few commits Delta also writes a Parquet checkpoint.
events/
├── part-00000-….snappy.parquet
├── part-00001-….snappy.parquet
└── _delta_log/
├── 00000000000000000000.json # version 0
├── 00000000000000000001.json # version 1
├── …
├── 00000000000000000010.checkpoint.parquet # full state at v10
└── _last_checkpoint # points to newest checkpointEach line of a commit file is one action:
| Action | What it records |
|---|---|
add | A data file joins the table, with its size, partition values and column statistics (min, max, null count). |
remove | A data file leaves the table. The file stays on storage until VACUUM: this is a tombstone. |
metaData | Schema, partition columns and table properties. |
protocol | The minimum reader and writer versions, and the table features in use. |
commitInfo | Who ran what, when: what DESCRIBE HISTORY shows. |
txn | An application ID and version, so streaming writes are applied exactly once. |
Walk through four commits
Step through what happens to the log and the files as an events table changes. Watch the difference between the files in the table and the files on storage.
v0 · Create
The command
df.write.format("delta").saveAsTable("events")
CREATE TABLE events USING delta AS SELECT * FROM staging;
What it writes · 00000000000000000000.json
{"protocol":{"minReaderVersion":1,"minWriterVersion":2}}
{"metaData":{"schemaString":"…","partitionColumns":[]}}
{"add":{"path":"part-000.parquet","dataChange":true,"stats":"{\"numRecords\":5000,…}"}}
{"add":{"path":"part-001.parquet","dataChange":true,…}}
{"commitInfo":{"operation":"CREATE TABLE AS SELECT",…}}In the table
On storage only (tombstoned)
v1 · Append
The command
new_rows.write.format("delta").mode("append").saveAsTable("events")
INSERT INTO events SELECT * FROM staging_day2;
What it writes · 00000000000000000001.json
{"add":{"path":"part-002.parquet","dataChange":true,…}}
{"commitInfo":{"operation":"WRITE","operationParameters":{"mode":"Append"},…}}In the table
On storage only (tombstoned)
v2 · Delete
The command
DeltaTable.forName(spark, "events").delete("country = 'FR'")
DELETE FROM events WHERE country = 'FR';
What it writes · 00000000000000000002.json
{"remove":{"path":"part-001.parquet","dataChange":true,"deletionTimestamp":…}}
{"add":{"path":"part-003.parquet","dataChange":true,…}}
{"commitInfo":{"operation":"DELETE",…}}In the table
On storage only (tombstoned)
v3 · Optimize
The command
DeltaTable.forName(spark, "events").optimize().executeCompaction()
OPTIMIZE events;What it writes · 00000000000000000003.json
{"remove":{"path":"part-000.parquet","dataChange":false,…}}
{"remove":{"path":"part-002.parquet","dataChange":false,…}}
{"remove":{"path":"part-003.parquet","dataChange":false,…}}
{"add":{"path":"part-004.parquet","dataChange":false,…}}
{"commitInfo":{"operation":"OPTIMIZE",…}}In the table
On storage only (tombstoned)
How a read works
Replaying every commit since version 0 would get slower forever. Checkpoints cap the work. When a query starts, the reader:
- 1Reads
_last_checkpointto find the newest checkpoint, say version 40. - 2Loads that checkpoint: the full list of live files at version 40.
- 3Lists the log for JSON commits after 40, and applies 41, 42, … in order.
- 4Uses the min and max statistics on each
addto skip files that cannot match the query's filter, then reads only the rest.
Step 4 is why Delta often reads far less data than a plain Parquet folder: file skipping uses statistics already in the log, with no listing and no opening of file footers.
delta.dataSkippingNumIndexedCols). Filter on column 40 and nothing is skipped. Put the columns you filter on first, or change the setting.How writers avoid conflicts
Delta uses optimistic concurrency: writers do not lock the table. They assume nobody else is writing, and check at the end.
- Read the table at the latest version, say 7.
- Write new Parquet files. Nobody can see them yet, because no commit lists them.
- Try to create
…08.jsonwith a "create only if it does not exist" write. - If another writer created version 8 first, check whether its changes conflict with ours. No conflict: retry as version 9. Conflict, such as both deleting from the same files: fail with a concurrent modification exception.
Step 3 is the whole trick. Atomicity comes from the storage system guaranteeing that only one writer can create a given file name. It is also why a crash after step 2 is harmless: the orphaned files are never referenced, and VACUUM cleans them up later.
Time travel
Because old commits stay in the log and removed files stay on storage, reading an older version just means replaying fewer commits.
# Read version 1 spark.read.option("versionAsOf", 1).table("events") # See every commit DeltaTable.forName(spark, "events").history().show() # Roll the table back (this writes a new commit) DeltaTable.forName(spark, "events").restoreToVersion(1)
-- Read version 1 SELECT * FROM events VERSION AS OF 1; -- See every commit DESCRIBE HISTORY events; -- Roll the table back (this writes a new commit) RESTORE TABLE events TO VERSION AS OF 1;
Retention: two clocks
Time travel needs two things to still exist: the log entries and the data files. Each has its own retention setting, and the shorter one wins.
| Table property | Default | Controls |
|---|---|---|
delta.logRetentionDuration | 30 days | How long commit files are kept. Cleaned up automatically when checkpoints are written. |
delta.deletedFileRetentionDuration | 7 days | How long a removed data file must be tombstoned before VACUUM may delete it. |
delta.checkpointInterval | 10 | Write a checkpoint every N commits. |
With the defaults, you have 30 days of history in the log but, after a VACUUM, only about 7 days of versions you can actually read.
The same idea in Iceberg and Hudi
| Delta Lake | Apache Iceberg | Apache Hudi | |
|---|---|---|---|
| Metadata | Ordered JSON commits plus Parquet checkpoints | Tree: metadata file → manifest list → manifests | Timeline of instants in .hoodie/ |
| Atomic commit | Create the next numbered file | Catalog swaps a pointer to the new metadata file | Complete an instant on the timeline |
| A version is called | Version | Snapshot | Instant (commit) |
| Cleanup | VACUUM | expire_snapshots | Cleaner service |
Common mistakes
Running VACUUM with a retention of 0 hours
spark.databricks.delta.retentionDurationCheck.enabled, and that check exists for a reason.Deleting data files directly in S3 or ADLS
Reading a Delta folder as plain Parquet
spark.read.parquet(path) ignores the log and returns every file, including removed ones, so you get duplicates and deleted rows back.Thinking OPTIMIZE changes the data
dataChange=false. Rows are identical, and streaming readers skip the commit.Key takeaways
- The table is the set of files the log says are live, not what is in the folder.
- A commit is one numbered JSON file of
addandremoveactions, and creating it is the atomic step. - Readers load the newest checkpoint and replay only the commits after it.
- Writers use optimistic concurrency: write files first, commit last, retry or fail on conflict.
- Time travel needs both the log (30 days) and the files (7 days after VACUUM).
Check yourself
3 questions1. A writer crashes after writing two Parquet files but before creating 00000000000000000006.json. What do readers see?
Show the answer
The table as of version 5; the new files are ignored. Readers only trust files listed in the log. With no version 6 commit, the two files are orphans that nobody reads, and VACUUM deletes them later.
2. A table is at version 47 with default settings. What does a reader load to get the current state?
Show the answer
The checkpoint at version 40, then commits 41 to 47. Checkpoints are written every 10 commits by default. The reader starts from the newest one (version 40) and replays the 7 commits after it.
3. You run VACUUM with the default retention, then query a version from 10 days ago. What happens?
Show the answer
It can fail, because files only that version needed may have been deleted. The log still describes the version, but VACUUM deletes data files that were removed from the table more than 7 days ago. If that old version needs any of them, the read fails.
Practice it
Interview problems that use this: write the PySpark, run it, and get graded on hidden tests.
Go deeper
Primary sources: Delta transaction log protocol · Delta Lake documentation