Skip to main content
Databricks beginner Lesson 2 of 10

Delta Lake: The Transaction Log

ACID on object storage — read the _delta_log, travel back to a previous version, MERGE upserts, and see schema enforcement reject a bad write.

Every table in the previous lesson was a Delta table. That means a directory of Parquet files plus a _delta_log that records what the table looked like at each version — and that log is the whole reason a pile of files in object storage behaves like a database.

What is on disk

dbutils.fs.ls("s3://bookshop-lake/raw/orders/")
[FileInfo(path='.../orders/_delta_log/', name='_delta_log/', size=0),
 FileInfo(path='.../orders/part-00000-8f2c1a44-...c000.snappy.parquet', name='part-00000-...', size=2048)]
dbutils.fs.ls("s3://bookshop-lake/raw/orders/_delta_log/")
[FileInfo(path='.../_delta_log/00000000000000000000.json', size=1204),
 FileInfo(path='.../_delta_log/00000000000000000001.json', size=988)]

One JSON file per commit, numbered in order. Each records the files added and removed:

print(dbutils.fs.head("s3://bookshop-lake/raw/orders/_delta_log/00000000000000000001.json", 600))
{"commitInfo":{"timestamp":1789459200000,"operation":"WRITE","operationParameters":{"mode":"Append"},"operationMetrics":{"numFiles":"1","numOutputRows":"5","numOutputBytes":"2048"}}}
{"add":{"path":"part-00000-8f2c1a44-...c000.snappy.parquet","size":2048,"modificationTime":1789459200000,"dataChange":true,"stats":"{\"numRecords\":5,\"minValues\":{\"order_id\":1001,\"amount\":8.75},\"maxValues\":{\"order_id\":1005,\"amount\":63.20}}"}}

Two things worth noticing. The commit is a single small JSON write, which is what makes it atomic — readers either see it or they do not. And each file carries stats with min and max per column, which is how Delta skips files at query time without an index.

History

describe history bookshop.raw.orders;
version  timestamp            operation  operationParameters      operationMetrics
-------  -------------------  ---------  -----------------------  ------------------------------------------
      1  2026-09-09 09:14:02  WRITE      {mode: Append}           {numFiles: 1, numOutputRows: 5}
      0  2026-09-09 09:14:00  CREATE TABLE {isManaged: true}      {}

Now make a mistake:

update bookshop.raw.orders set amount = amount * 100;
num_affected_rows
-----------------
                5
select order_id, amount from bookshop.raw.orders order by order_id limit 3;
order_id  amount
--------  -------
    1001  2550.00
    1002  1200.00
    1003  4000.00
describe history bookshop.raw.orders limit 2;
version  timestamp            operation  operationMetrics
-------  -------------------  ---------  -----------------------------------------------------
      2  2026-09-09 09:22:41  UPDATE     {numUpdatedRows: 5, numCopiedRows: 0, numOutputRows: 5}
      1  2026-09-09 09:14:02  WRITE      {numFiles: 1, numOutputRows: 5}

Time travel

select order_id, amount from bookshop.raw.orders version as of 1 order by order_id limit 3;
order_id  amount
--------  ------
    1001   25.50
    1002   12.00
    1003   40.00
select order_id, amount from bookshop.raw.orders timestamp as of '2026-09-09 09:20:00' limit 3;
order_id  amount
--------  ------
    1001   25.50
    1002   12.00
    1003   40.00

The UPDATE did not modify a file — Delta wrote a new Parquet file and logged the old one as removed. Version 1 still points at the original file, so reading it is a normal query.

Restoring is one statement:

restore table bookshop.raw.orders to version as of 1;
table_size_after_restore  num_of_files_after_restore  num_removed_files  num_restored_files
------------------------  --------------------------  -----------------  ------------------
                    2048                           1                  1                   1

RESTORE is itself a new commit rather than a rewrite of history, so you can undo the undo. The whole audit trail stays intact — important when someone asks what the table looked like before the fix.

MERGE

The statement that does the real work in a lakehouse — upserts in one atomic operation:

create or replace temp view orders_updates as
select * from values
    (1003, 1, date'2026-01-07', 'refunded',  40.00),   -- status changed
    (1006, 4, date'2026-01-14', 'completed', 31.20)    -- new row
    as t(order_id, customer_id, ordered_at, status, amount);

merge into bookshop.raw.orders t
using orders_updates s
on t.order_id = s.order_id
when matched then update set *
when not matched then insert *;
num_affected_rows  num_updated_rows  num_deleted_rows  num_inserted_rows
-----------------  ----------------  ----------------  -----------------
                2                 1                 0                  1
select order_id, status, amount from bookshop.raw.orders order by order_id;
order_id  status     amount
--------  ---------  ------
    1001  completed   25.50
    1002  completed   12.00
    1003  refunded    40.00
    1004  completed    8.75
    1005  pending     63.20
    1006  completed   31.20

update set * and insert * map columns by name, which keeps the statement short and survives a new column being added to both sides. Spell the columns out when the source and target genuinely differ.

MERGE is idempotent for a given source: run it twice and the second run reports one updated row and zero inserted, rather than duplicating. That property is what makes a retried pipeline step safe.

Conditional clauses handle deletes and let you skip no-op updates:

merge into bookshop.raw.orders t
using orders_updates s on t.order_id = s.order_id
when matched and s.status = 'cancelled' then delete
when matched and t.status <> s.status then update set status = s.status
when not matched then insert *;
num_affected_rows  num_updated_rows  num_deleted_rows  num_inserted_rows
-----------------  ----------------  ----------------  -----------------
                1                 0                 0                  1

Zero updates this time — the t.status <> s.status guard skipped the row whose status already matched. On a wide table that guard avoids rewriting files for no reason.

Schema enforcement

from pyspark.sql import Row

bad = spark.createDataFrame([Row(order_id=1007, customer_id=2, amount="twelve pounds")])
bad.write.mode("append").saveAsTable("bookshop.raw.orders")
AnalysisException: Failed to merge fields 'amount' and 'amount'.
Failed to merge incompatible data types DecimalType(10,2) and StringType

A schema mismatch detected when writing to the Delta table.
To enable schema migration using DataFrameWriter or DataStreamWriter,
please set: '.option("mergeSchema", "true")'.

Rejected before anything was written. A plain Parquet directory would have accepted this happily and produced a table that fails at read time — usually months later, for someone else.

Adding a genuinely new column is allowed when you ask for it:

from pyspark.sql.functions import lit

(spark.table("bookshop.raw.orders").limit(1)
    .withColumn("channel", lit("web"))
    .write.mode("append").option("mergeSchema", "true")
    .saveAsTable("bookshop.raw.orders"))
OK
describe bookshop.raw.orders;
col_name     data_type
-----------  -------------
order_id     bigint
customer_id  bigint
ordered_at   date
status       string
amount       decimal(10,2)
channel      string

Existing rows get NULL for the new column. Type changes still need an explicit overwriteSchema and a rewrite — widening a column is not free.

Concurrent writers

Delta uses optimistic concurrency: two writers proceed independently and the second to commit re-checks for conflicts.

ConcurrentAppendException: Files were added to the root of the table by a concurrent update.
Please try the operation again.
Conflicting commit: {"timestamp":1789459260000,"operation":"MERGE"}

Two writers appending to different partitions do not conflict. Two MERGE statements over overlapping rows do, and the loser must retry — which is safe, because nothing was committed. Partition or filter concurrent writers so they touch disjoint data, and retry on this exception rather than serialising everything.

Cleaning up

vacuum bookshop.raw.orders retain 168 hours dry run;
path
----------------------------------------------------------
s3://bookshop-lake/raw/orders/part-00000-3a1f...snappy.parquet
s3://bookshop-lake/raw/orders/part-00000-7c2e...snappy.parquet
vacuum bookshop.raw.orders;
path
----
s3://bookshop-lake/raw/orders

VACUUM deletes data files no longer referenced by the current version, keeping 7 days by default. The trade-off is direct: vacuuming shortens how far back you can time travel. Version 1 may still be in the log, but if its Parquet file is gone the read fails:

FileNotFoundException: s3://bookshop-lake/raw/orders/part-00000-3a1f...snappy.parquet
It is possible the underlying files have been updated or deleted by VACUUM.

Always DRY RUN first, and never lower the retention below the duration of your longest running job — a query that started before the vacuum can have its files deleted underneath it.

Practice

1. Run an UPDATE and read the table at the previous version.
select sum(amount) from bookshop.raw.orders;
select sum(amount) from bookshop.raw.orders version as of 1;
sum(amount)
-----------
   14945.00

sum(amount)
-----------
     149.45

Both are normal queries against the same table name. Comparing versions before restoring tells you the scale of the mistake, which is worth knowing before you decide how to fix it.

2. Run the same MERGE twice.
-- first run
num_updated_rows  num_inserted_rows
----------------  -----------------
               1                  1
-- second run
num_updated_rows  num_inserted_rows
----------------  -----------------
               1                  0

No duplicate on the second run. The one update is the row being rewritten with identical values — add the t.status <> s.status guard and that becomes zero too, saving a file rewrite.

3. Write a wrong data type into a column.
AnalysisException: Failed to merge incompatible data types DecimalType(10,2) and StringType

Nothing was written. Schema enforcement is the feature that most distinguishes a Delta table from a directory of Parquet — the bad write fails at the boundary rather than silently creating an unreadable table.

4. Inspect the transaction log after three operations.
describe history bookshop.raw.orders;
version  operation  operationMetrics
-------  ---------  -------------------------------------------
      4  MERGE      {numTargetRowsInserted: 1, numTargetRowsUpdated: 1}
      3  RESTORE    {numRestoredFiles: 1}
      2  UPDATE     {numUpdatedRows: 5}
      1  WRITE      {numOutputRows: 5}
      0  CREATE TABLE {}

Five versions, each with the metrics for what it did. This is an audit log you get for free — including who ran each operation in the userName column, which is often the fastest answer to “when did this number change”.

Next: Unity Catalog — governing all of this with grants that follow the data.

Frequently Asked Questions

What makes a Delta table different from Parquet files?
A `_delta_log` directory beside the data. It records every commit as an ordered set of JSON files listing which Parquet files are part of the table at that version, which is what gives you atomicity, time travel and schema enforcement over storage that has none of them.
How does Delta Lake give ACID guarantees on object storage?
Writers stage new Parquet files, then atomically commit a log entry naming them. A reader resolves the file list from the log, so it never sees a half-written commit. Concurrent writers use optimistic concurrency — the second one to commit re-checks for conflicts and retries or fails.
How far back can Delta time travel go?
As far as the log and data files are retained — 30 days of log history by default, but only until `VACUUM` removes the underlying files, which defaults to a 7-day retention. Time travel beyond the vacuum horizon fails even though the log entry still exists.
When should I use MERGE instead of INSERT?
Whenever a row might already exist — upserts, CDC application, and deduplicated loads. `MERGE` handles matched and unmatched rows in one atomic statement, so a retried run cannot produce duplicates the way a bare `INSERT` would.