Skip to content
Good engineers know 8 min read · Delta 2 practice problems ↓

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.

You will learn

  • What is inside _delta_log and 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

Comfortable with these? Read on.

TL;DR Each write adds one numbered JSON file listing files added and removed. The table is whatever you get by replaying those files in order. The commit is atomic because creating the next file either succeeds or it does not.

Why it exists

Picture a plain Parquet table: a folder, and "the table" is every file in it. That definition breaks in three ways.

  1. 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.
  2. 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.
  3. 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

Entries: deposit, withdrawal. Balance = sum of entries. Past balance = stop at a date.

Delta log

Entries: 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 checkpoint

Each line of a commit file is one action:

ActionWhat it records
addA data file joins the table, with its size, partition values and column statistics (min, max, null count).
removeA data file leaves the table. The file stays on storage until VACUUM: this is a tombstone.
metaDataSchema, partition columns and table properties.
protocolThe minimum reader and writer versions, and the table features in use.
commitInfoWho ran what, when: what DESCRIBE HISTORY shows.
txnAn 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

Creating the table writes two Parquet files and the first commit. Along with the two add actions, version 0 records the protocol and the schema.

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

part-000.parquetpart-001.parquet

On storage only (tombstoned)

None.

v1 · Append

An append only adds. One new file, one new commit, and nothing removed. Readers on version 0 are unaffected while this runs.

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

part-000.parquetpart-001.parquetpart-002.parquet

On storage only (tombstoned)

None.

v2 · Delete

Parquet files are immutable, so Delta finds the files holding French rows (only part-001, thanks to its statistics), writes part-003 with the other rows, then removes part-001 and adds part-003 in one commit. This assumes deletion vectors are off; with them on, Delta marks the deleted rows in a small side file instead.

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

part-000.parquetpart-002.parquetpart-003.parquet

On storage only (tombstoned)

part-001.parquet

v3 · Optimize

Compaction merges the three small files into one. The rows do not change, so every action is marked dataChange=false. The old files stay on storage, which is what lets you still read version 2, until VACUUM deletes them.

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

part-004.parquet

On storage only (tombstoned)

part-000.parquetpart-001.parquetpart-002.parquetpart-003.parquet

How a read works

Replaying every commit since version 0 would get slower forever. Checkpoints cap the work. When a query starts, the reader:

  1. 1
    Reads _last_checkpoint to find the newest checkpoint, say version 40.
  2. 2
    Loads that checkpoint: the full list of live files at version 40.
  3. 3
    Lists the log for JSON commits after 40, and applies 41, 42, … in order.
  4. 4
    Uses the min and max statistics on each add to 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.

Why this matters: statistics are collected on the first 32 columns by default (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.

  1. Read the table at the latest version, say 7.
  2. Write new Parquet files. Nobody can see them yet, because no commit lists them.
  3. Try to create …08.json with a "create only if it does not exist" write.
  4. 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.

PySparkSpark SQL
# 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 propertyDefaultControls
delta.logRetentionDuration30 daysHow long commit files are kept. Cleaned up automatically when checkpoints are written.
delta.deletedFileRetentionDuration7 daysHow long a removed data file must be tombstoned before VACUUM may delete it.
delta.checkpointInterval10Write 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 LakeApache IcebergApache Hudi
MetadataOrdered JSON commits plus Parquet checkpointsTree: metadata file → manifest list → manifestsTimeline of instants in .hoodie/
Atomic commitCreate the next numbered fileCatalog swaps a pointer to the new metadata fileComplete an instant on the timeline
A version is calledVersionSnapshotInstant (commit)
CleanupVACUUMexpire_snapshotsCleaner service

Common mistakes

Running VACUUM with a retention of 0 hours

It deletes files that running queries and streams may still be reading, and ends time travel. Delta blocks it unless you turn off spark.databricks.delta.retentionDurationCheck.enabled, and that check exists for a reason.

Deleting data files directly in S3 or ADLS

The log still lists them, so every read fails with a missing file error. Always change data through Delta commands.

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

It rewrites files but marks its actions 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 add and remove actions, 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 questions

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

Solve: Rebuild a Table from Its Transaction Log →

Go deeper

Primary sources: Delta transaction log protocol · Delta Lake documentation