Delta Lake: Reliable Lakehouse Storage with ACID Transactions
Data lakes built on object storage are cheap and scalable, but plain file formats give you no guarantees: a half-finished write can leave readers with corrupt data, and concurrent jobs can clobber each other. Delta Lake fixes this by adding a transaction log on top of Parquet, bringing ACID transactions, schema enforcement, and time travel to the lake. This tutorial walks through Delta Lake from first principles using two practical paths: the lightweight Python deltalake library (no Spark required) and Apache Spark with delta-spark.
What Delta Lake Is
Delta Lake is an open table format. A Delta table is not a single file but a directory that contains:
- One or more Parquet data files holding the actual rows.
- A transaction log directory named
deltalog, containing ordered JSON commit files (00000000000000000000.json,00000000000000000001.json, and so on) plus periodic Parquet checkpoints.
Every change to the table is recorded as an atomic commit in the log. Readers consult the log to learn exactly which Parquet files make up the current version of the table. Because the log is the source of truth, a reader never sees a partially written file, and two writers can detect that they conflicted.
This is the core of the lakehouse idea: keep the low cost and openness of a data lake (Parquet on S3, ADLS, GCS, or local disk), but add the reliability and transactional semantics you would expect from a data warehouse.
Why Not Just Parquet?
Plain Parquet is excellent columnar storage, but it has no notion of a table version or a commit. If a job writes ten Parquet files and crashes after five, a reader scanning the directory will pick up an inconsistent mix. There is no atomic "append", no safe concurrent update, no way to roll back, and schema changes are entirely manual. Delta Lake keeps Parquet as the storage layer and layers transactional metadata on top.
The Transaction Log in Detail
A typical Delta directory looks like this:
orders/
deltalog/
00000000000000000000.json
00000000000000000001.json
00000000000000000002.json
lastcheckpoint
part-00000-....snappy.parquet
part-00001-....snappy.parquet
Each JSON commit describes actions: files added (add), files removed (remove), metadata changes, and protocol changes. Version 0 is the table creation; each subsequent commit increments the version by one. This monotonic version number is what makes time travel possible, and the remove actions are what let VACUUM later reclaim disk space.
Path 1: The Python deltalake Library (No Spark)
The deltalake package (the delta-rs project) is a native Rust implementation with Python bindings. It reads and writes Delta tables without a JVM or Spark cluster, which makes it ideal for single-node ETL, notebooks, and embedding Delta into existing pandas or PyArrow pipelines.
Installation
pip install "deltalake>=0.18" pandas pyarrow
Creating and Appending to a Table
We will model an orders table throughout this tutorial.
import pandas as pd
from deltalake import writedeltalake, DeltaTable
tablepath = "data/orders"
orders = pd.DataFrame(
{
"orderid": [1, 2, 3],
"customer": ["alice", "bob", "carol"],
"amount": [120.0, 80.5, 45.0],
"status": ["paid", "paid", "pending"],
"region": ["west", "east", "west"],
}
)
First write creates the table at version 0.
writedeltalake(tablepath, orders, mode="overwrite")
To add more rows without rewriting existing data, use mode="append":
neworders = pd.DataFrame(
{
"orderid": [4, 5],
"customer": ["dave", "erin"],
"amount": [200.0, 15.0],
"status": ["paid", "paid"],
"region": ["east", "west"],
}
)
writedeltalake(tablepath, neworders, mode="append")
Reading the Table
dt = DeltaTable(tablepath)
As a pandas DataFrame
df = dt.to
pandas()
print(df)
As a PyArrow table (useful for streaming into other engines)
arrowtable = dt.topyarrowtable()
Inspect the current version
print("current version:", dt.version())
You can also push down a column projection and a predicate so that only the relevant Parquet files and columns are read:
paid = dt.topandas(
columns=["orderid", "customer", "amount"],
filters=[("status", "=", "paid")],
)
Schema Enforcement and Evolution
By default Delta rejects a write whose schema does not match the table. This is schema enforcement, and it prevents accidental column drift.
# This would raise because discount is a new column.
bad = pd.DataFrame({"orderid": [6], "discount": [0.1]})
writedeltalake(tablepath, bad, mode="append") # -> schema mismatch error
When a schema change is intentional, opt in explicitly with schemamode="merge":
extended = pd.DataFrame(
{
"orderid": [6],
"customer": ["frank"],
"amount": [99.0],
"status": ["paid"],
"region": ["east"],
"discount": [0.10], # brand-new column
}
)
writedeltalake(tablepath, extended, mode="append", schemamode="merge")
Existing rows get null for the new discount column. Use schemamode="overwrite" together with mode="overwrite" only when you deliberately want to replace the schema.
Time Travel with deltalake
Because every commit is versioned, you can load any historical state of the table.
# Load a specific version
v0 = DeltaTable(tablepath, version=0).topandas()
Or load as of a timestamp
dt = DeltaTable(tablepath)
dt.loadasversion(1)
snapshot = dt.topandas()
Inspect the full history
for commit in DeltaTable(tablepath).history():
print(commit.get("version"), commit.get("operation"), commit.get("timestamp"))
DELETE, UPDATE, and MERGE with deltalake
The deltalake library exposes table-level mutations directly.
dt = DeltaTable(tablepath)
DELETE rows matching a predicate
dt.delete("status = 'pending'")
UPDATE rows
dt.update(
predicate="region = 'west'",
updates={"status": "'shipped'"},
)
Upserts use the merge API. The pattern is: match on a key, update when matched, insert when not matched.
import pyarrow as pa
updates = pa.table(
{
"orderid": [4, 7], # 4 exists -> update; 7 is new -> insert
"customer": ["dave", "gina"],
"amount": [222.0, 60.0],
"status": ["refunded", "paid"],
"region": ["east", "west"],
}
)
(
DeltaTable(tablepath)
.merge(
source=updates,
predicate="target.orderid = source.orderid",
sourcealias="source",
targetalias="target",
)
.whenmatchedupdateall()
.whennotmatchedinsertall()
.execute()
)
Maintenance: Compaction and VACUUM
Frequent small appends create many small Parquet files, which slows reads. Compaction rewrites them into fewer, larger files.
dt = DeltaTable(tablepath)
Compact small files into larger ones
dt.optimize.compact()
Remove data files no longer referenced by the log.
retentionhours guards against deleting files still needed for time travel.
dt.vacuum(retentionhours=168, dryrun=False, enforceretentionduration=True)
OPTIMIZE (compaction) never changes the logical contents of the table; it only reorganizes files. VACUUM physically deletes Parquet files that were marked remove in older commits and are older than the retention window. After a VACUUM you lose the ability to time-travel to versions that relied on those deleted files.
Path 2: Apache Spark with delta-spark
For distributed workloads, MERGE INTO at scale, Z-ordering, and SQL access, use Delta on Spark.
Installation and Session Setup
pip install pyspark==3.5.1 delta-spark==3.2.0
from pyspark.sql import SparkSession
from delta import configuresparkwithdeltapip
builder = (
SparkSession.builder.appName("delta-orders")
.config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
.config(
"spark.sql.catalog.sparkcatalog",
"org.apache.spark.sql.delta.catalog.DeltaCatalog",
)
)
spark = configuresparkwithdeltapip(builder).getOrCreate()
configuresparkwithdeltapip automatically adds the matching Delta Maven package to the session, so versions stay aligned with the pip install.
Creating and Appending with Spark
data = [
(1, "alice", 120.0, "paid", "west"),
(2, "bob", 80.5, "paid", "east"),
(3, "carol", 45.0, "pending", "west"),
]
columns = ["orderid", "customer", "amount", "status", "region"]
df = spark.createDataFrame(data, columns)
Write a partitioned Delta table
(
df.write.format("delta")
.mode("overwrite")
.partitionBy("region")
.save("/data/ordersspark")
)
Append more rows
more = spark.createDataFrame([(4, "dave", 200.0, "paid", "east")], columns)
more.write.format("delta").mode("append").save("/data/ordersspark")
You can also register the path as a SQL table:
CREATE TABLE orders
USING DELTA
LOCATION '/data/ordersspark';
Schema Evolution with mergeSchema
from pyspark.sql.functions import lit
evolved = (
spark.createDataFrame([(5, "erin", 30.0, "paid", "west")], columns)
.withColumn("discount", lit(0.05))
)
(
evolved.write.format("delta")
.mode("append")
.option("mergeSchema", "true")
.save("/data/ordersspark")
)
Time Travel with Spark
# By version
v0 = spark.read.format("delta").option("versionAsOf", 0).load("/data/ordersspark")
By timestamp
ts = (
spark.read.format("delta")
.option("timestampAsOf", "2026-06-01 00:00:00")
.load("/data/ordersspark")
)
In SQL:
SELECT FROM orders VERSION AS OF 0;
SELECT
FROM orders TIMESTAMP AS OF '2026-06-01 00:00:00';
DESCRIBE HISTORY orders;
UPSERT with MERGE INTO
MERGE INTO is the workhorse for change-data-capture and idempotent upserts.
MERGE INTO orders AS target
USING updates AS source
ON target.orderid = source.orderid
WHEN MATCHED AND source.status = 'cancelled' THEN
DELETE
WHEN MATCHED THEN
UPDATE SET
target.amount = source.amount,
target.status = source.status
WHEN NOT MATCHED THEN
INSERT (orderid, customer, amount, status, region)
VALUES (source.orderid, source.customer, source.amount, source.status, source.region);
The equivalent using the DataFrame API:
from delta.tables import DeltaTable as SparkDeltaTable
target = SparkDeltaTable.forPath(spark, "/data/ordersspark")
source = spark.createDataFrame(
[(4, "dave", 222.0, "refunded", "east")], columns
)
(
target.alias("t")
.merge(source.alias("s"), "t.orderid = s.orderid")
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.execute()
)
DELETE and UPDATE in Spark
DELETE FROM orders WHERE status = 'pending';
UPDATE orders SET status = 'shipped' WHERE region = 'west';
OPTIMIZE, Z-Ordering, and VACUUM
-- Compact small files
OPTIMIZE orders;
-- Co-locate related data to speed up filtered reads
OPTIMIZE orders ZORDER BY (customer);
-- Reclaim space from files older than the retention window
VACUUM orders RETAIN 168 HOURS;
Z-ordering reorganizes data within files so that rows with similar values in the Z-order columns sit together. When you later filter on those columns, Delta can skip more files via data-skipping statistics. It is most useful for high-cardinality columns you filter on often, such as customer or orderid.
Reading a Delta Table Without Spark
From pandas via deltalake
from deltalake import DeltaTable
df = DeltaTable("/data/ordersspark").topandas(filters=[("region", "=", "west")])
From DuckDB
DuckDB can scan Delta tables directly through its delta extension.
INSTALL delta;
LOAD delta;
SELECT region, count(*) AS n, sum(amount) AS total
FROM deltascan('/data/ordersspark')
GROUP BY region
ORDER BY total DESC;
This is a practical way to run analytical SQL over a Delta table from a laptop, with no Spark and no separate database server.
How Delta Compares
Delta vs Plain Parquet
| Aspect | Plain Parquet | Delta Lake |
| --- | --- | --- |
| Atomic writes | No | Yes (via the log) |
| Concurrent writers | Unsafe | Conflict detection |
| Schema enforcement | Manual | Built in |
| Time travel | No | Yes |
| UPDATE / DELETE / MERGE | Rewrite by hand | First-class operations |
| Small-file compaction | Manual | OPTIMIZE |
Delta still stores rows as Parquet, so you keep Parquet's compression and columnar performance while gaining transactional behavior.
Delta vs Iceberg vs Hudi
Apache Iceberg and Apache Hudi are the two main alternatives, and all three are open table formats solving the same class of problems.
- Delta Lake has the deepest integration with the Spark ecosystem and Databricks, plus a strong non-Spark path through
delta-rs. The transaction log is file-based and simple to reason about. - Apache Iceberg uses a metadata-tree design with snapshots and manifest files, has broad engine support (Spark, Flink, Trino, Dremio), and is often chosen for vendor-neutral, multi-engine architectures.
- Apache Hudi was designed around streaming upserts and incremental processing, offering copy-on-write and merge-on-read table types tuned for high-frequency updates.
For a Spark- or Python-centric team that wants ACID tables with minimal moving parts, Delta is a pragmatic default. If multi-engine interoperability is the priority, evaluate Iceberg; if low-latency streaming upserts dominate, look closely at Hudi.
Best Practices
- Partition by low-cardinality columns you filter on frequently (for example
regionor an event date), and avoid over-partitioning, which creates many tiny files. - Use
MERGE INTOfor idempotent ingestion so that reprocessing the same batch does not create duplicates. - Run
OPTIMIZEregularly on tables that receive many small appends, and addZORDER BYon the columns you filter most. - Set a sensible
VACUUMretention (the default is 7 days). Do not lower it below the longest time-travel window you rely on, or you will break historical reads. - Opt into schema evolution explicitly with
mergeSchema/schemamode="merge"rather than turning it on globally, so accidental schema drift is still caught. - Keep the writer versions aligned: with
delta-spark, letconfiguresparkwithdeltapipmanage the package version; withdeltalake, pin a minimum version in your requirements. - Prefer
delta-rsfor single-node jobs and Spark for distributed workloads; both read and write the same on-disk format, so you can mix them.
Conclusion and Key Takeaways
Delta Lake turns a directory of Parquet files into a reliable, transactional table by adding a versioned deltalog. The key points to remember:
- A Delta table is Parquet data plus a transaction log; the log is the source of truth and the basis for ACID guarantees.
- You have two practical entry points: the
deltalakePython library for Spark-free, single-node work, anddelta-sparkfor distributed processing and SQL. - Schema enforcement protects you by default; opt into evolution deliberately.
- Time travel lets you read any past version by number or timestamp, which is invaluable for debugging and reproducibility.
MERGE,UPDATE, andDELETEare first-class, andOPTIMIZE/Z-ordering plusVACUUMkeep tables fast and tidy.- Compared to plain Parquet, Delta adds reliability with little overhead; compared to Iceberg and Hudi, it is a strong choice for Spark- and Python-centric teams.
With these building blocks you can run a dependable lakehouse on commodity object storage, and read the same tables from Spark, pandas, or DuckDB as your needs change.