This is part one of three. It covers everything you need to do real work with Delta Lake: create a table on your laptop, write to it, read an old version of it back, update and delete individual rows, let its schema change on purpose but never by accident, stream into it, and keep it tidy. Mid-level and Senior take the same topics further; nothing here is wasted.
Delta Lake is a library, not a server, and it rides on Apache Spark. Everything below runs against Delta Lake 4.0 with Apache Spark 4.0.x, which is the pair the current quick start documents. You will need a terminal, a Java runtime, and Python. No cloud account, no cluster, no credit card.
Each section ends with a Try it task. Do them as you go. Delta's guarantees are the kind of thing you believe only after you have broken a table, read it back from before you broke it, and watched the numbers come back correct.
What Delta Lake is, and the problem it solves
Delta Lake is a table format: a convention for laying out files in object storage so that a directory of Parquet files behaves like a database table. It gives you atomic writes, correct concurrent reads, row-level updates and deletes, a readable history of every change, and a schema the table itself enforces.
That sentence contains the whole value proposition, but it only lands once you know what the alternative looks like. So start with the alternative.
A data lake, in the ordinary sense, is a bucket with files in it. You land CSV or Parquet in s3://my-bucket/events/, you point Spark at the folder, and Spark reads whatever files it finds. That is wonderfully simple and it is why data lakes won: storage is cheap, any engine can read a folder, and nobody owns the gatekeeping.
The simplicity has a cost, and the cost is that a folder is not a table. A folder has no notion of a transaction. It has no notion of "the current contents". It has no notion of a schema beyond whatever happens to be inside each file. Every one of those gaps turns into a production incident eventually, and the incidents are the reason Delta Lake exists:
- A job writing 400 Parquet files fails after 300. The folder now holds three quarters of a day's data and nothing marks it as incomplete. Any reader that arrives before you clean up gets a wrong answer and no warning.
- A dashboard reads the folder while that job is halfway through writing. It sees a partial day. The number it shows is not a number that was ever true.
- Someone needs to delete one customer's rows for a data-protection request. There is no
DELETE. You read the whole partition, filter in memory, write it back out, and hope no reader arrives during the gap. - A new field arrives as a string where it used to be an integer. One file now disagrees with the rest, and the read fails weeks later with a type error pointing at a file nobody remembers writing.
- A model trained in March scored badly and you want to retrain on exactly the data it saw. That data has been overwritten four times since. The training set is gone.
What those five have in common is that none of them is a bug in anyone's code. They are all consequences of storing tabular data in a system that only understands files. The usual response, before table formats existed, was a thicket of conventions: write to a temporary prefix and rename at the end, drop a _SUCCESS marker and teach every reader to check for it, keep a side table of which partitions are complete, never overwrite anything and let readers de-duplicate. Each convention solves one of the five and relies on every single consumer of the data following it. The first consumer that does not — a notebook, a BI tool, a new hire's ad-hoc query — gets a wrong answer silently.
Delta Lake's move is to push those conventions down into the storage layout itself, so that correctness does not depend on everyone remembering the rules. A reader that uses the Delta format gets atomicity and isolation whether or not it knows why, and a reader that ignores Delta and lists the directory is obviously wrong rather than subtly wrong. It is worth naming the two other table formats that make the same move with different details, Apache Iceberg and Apache Hudi, because you will be asked how they compare. At this level the honest answer is that all three solve the same problem with a metadata layer over Parquet, and the differences that matter in practice are ecosystem support and which one your engine and catalogue already speak.
Delta Lake fixes all five with one mechanism, which is the next section. The mechanism is small enough to hold in your head, and that is the reason this guide spends a whole section on it before showing you a single command.
- Pick a dataset at work or in a course that lives as files in a folder.
- Write down how you would delete one row from it, and how a reader could tell whether the last write finished.
- Write down whether you could reconstruct what the folder held last Tuesday.
The transaction log: the one idea
A Delta table is a directory. Inside it you find Parquet data files, exactly the ones a plain Parquet dataset would have, plus one extra folder called _delta_log/. That folder is the entire difference.
/tmp/delta-table/
├── _delta_log/
│ ├── 00000000000000000000.json
│ ├── 00000000000000000001.json
│ └── 00000000000000000002.json
├── part-00000-....snappy.parquet
├── part-00001-....snappy.parquet
└── part-00002-....snappy.parquet
Each numbered JSON file is one commit. A commit is a small list of facts: these data files were added to the table, these data files were removed from it, the schema is now this, these table properties are now set. The commits are numbered from zero and never modified after they are written.
To find out what the table contains right now, a reader does not list the directory. It replays the log from commit zero, applying adds and removes in order, and ends up with a list of exactly which Parquet files are part of the table at this moment. That list is called a snapshot. Then, and only then, does the reader open those files.
Four consequences follow from that, and they are the four things to remember:
Writes are atomic, because a write becomes visible in one step. A job writes all its Parquet files first. Those files exist in the directory but no commit mentions them, so they are invisible — not part of the table. Only when every file is written does the job add the commit that names them. If the job dies halfway, the orphan Parquet files sit there unreferenced, and readers never see a partial result. There is no half-written table.
Reads are consistent, because the reader pins a snapshot. A query that starts at commit 7 reads the file list from commit 7 and keeps using it, even if commits 8 and 9 land while it runs. This is called snapshot isolation. Your dashboard sees a state the table really was in, not a smear across two writes.
The old versions are still there. Commit 5 removed some files from the table, but removing a file from the table is a line in a JSON file, not a deletion from storage. The Parquet files are still on disk. So you can replay the log up to commit 4 instead and read the table exactly as it was. That is time travel, and it is free: it falls out of the design rather than being a feature bolted on.
The log is the only source of truth. A Parquet file sitting in the directory that no commit mentions is not in the table. This is the single most important sentence in this guide for debugging. It explains why copying a file into the folder does nothing, why the directory can be much larger than the table, and why you must never delete files from a Delta directory by hand.
There is a fifth consequence that is less obvious and worth a paragraph of its own, because it explains why Delta queries can be fast on tables with millions of files. The commit does not only name the files it added; it also records statistics about them, such as the minimum and maximum value of each of the leading columns inside that file. So when you query WHERE order_date = '2026-09-01', Delta can consult the log, see that a given file's order_date range does not include that day, and skip the file without opening it. This is called data skipping, it needs no index and no tuning to start working, and it is the reason the log is not merely bookkeeping but part of the query path. By default statistics are collected for the first 32 columns, which is the delta.dataSkippingNumIndexedCols property you will meet later.
Replaying thousands of JSON files would get slow, so Delta periodically writes a checkpoint: a Parquet file in _delta_log/ that summarises the state up to that commit. A reader finds the newest checkpoint and replays only the commits after it. You do not manage checkpoints; they happen.
rm, move, or copy Parquet files inside a Delta table directory, and do not write into it with a plain parquet writer. The log will still reference files you deleted, and the table will fail on read with a missing-file error. Everything is done through Delta's own commands, and the one safe way to remove old files is VACUUM, covered later.
- Nothing to run yet. Instead, explain the log out loud in three sentences: what a commit records, what a snapshot is, and why a failed write is invisible.
- Then answer: if the directory holds 50 Parquet files and the latest commit references 12 of them, how many files does the table have?
Installing Delta Lake and checking the setup
Delta Lake is a jar that plugs into Spark. There is nothing to run and no service to keep alive, so "installing Delta" really means "getting a working Spark with the Delta jar on its classpath".
You need three things: a Java runtime, Python with PySpark, and the delta-spark package. The Delta and Spark versions must be a documented pair — Delta Lake 4.0.0 goes with Spark 4.0.x, and Delta Lake 3.3.x goes with Spark 3.5.x. Mixing lines across that boundary produces binary-incompatibility errors rather than a clear message, so pick the pair first and install second.
python3 -m venv .venv
source .venv/bin/activate
pip install pyspark==4.0.0 delta-spark==4.0.0
On macOS, install a JDK with Homebrew and point JAVA_HOME at it so Spark can find it:
brew install openjdk@17
export JAVA_HOME="$(/usr/libexec/java_home -v 17)"
java -version
On Linux, your distribution's OpenJDK 17 package does the same job. On Windows, the path of least resistance is WSL2, where you follow the Linux instructions; a native Windows Spark setup additionally needs the Hadoop native binaries on PATH, which is a detour with nothing to teach you about Delta.
Now the part people get wrong. Having the jar is not enough: Delta extends Spark's SQL parser and replaces Spark's default catalog, and both of those are opt-in configuration. Two settings turn them on:
import pyspark
from delta import configure_spark_with_delta_pip
builder = (
pyspark.sql.SparkSession.builder.appName("DeltaFirstSteps")
.config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
.config(
"spark.sql.catalog.spark_catalog",
"org.apache.spark.sql.delta.catalog.DeltaCatalog",
)
)
spark = configure_spark_with_delta_pip(builder).getOrCreate()
print(spark.version)
spark.sql.extensions is what teaches Spark SQL the Delta-only statements — MERGE, OPTIMIZE, VACUUM, DESCRIBE HISTORY, VERSION AS OF. spark.sql.catalog.spark_catalog is what lets Delta take over table resolution so that delta./path`` and Delta table properties work. configure_spark_with_delta_pip is a helper from the delta Python package that adds the matching Maven coordinates to the session, which is why you do not have to type a version string here.
If you prefer the interactive shells, pass the same three things on the command line. This form is useful to memorise, because it is how you check a cluster quickly:
pyspark --packages io.delta:delta-spark_2.13:4.0.0 \
--conf "spark.sql.extensions=io.delta.sql.DeltaSparkSessionExtension" \
--conf "spark.sql.catalog.spark_catalog=org.apache.spark.sql.delta.catalog.DeltaCatalog"
bin/spark-sql --packages io.delta:delta-spark_2.13:4.0.0 \
--conf "spark.sql.extensions=io.delta.sql.DeltaSparkSessionExtension" \
--conf "spark.sql.catalog.spark_catalog=org.apache.spark.sql.delta.catalog.DeltaCatalog"
The _2.13 in delta-spark_2.13 is the Scala version Spark itself was built with. Delta 4.x is Scala 2.13 only. The first --packages run downloads jars from Maven Central, so it needs network access and takes a minute; afterwards they are cached locally.
spark.range(0, 5).write.format("delta").save("/tmp/delta-check"). If it succeeds, the jar is on the classpath. If a subsequent DESCRIBE HISTORY also succeeds, the extension and catalog configs are right too. The two failures look different and the next section shows why.
- Create the virtual environment and install the two packages at the exact versions above.
- Save the session script, run it, and confirm it prints a 4.0.x Spark version.
- Deliberately comment out the
spark.sql.extensionsline and re-run. Keep the error you get; you will recognise it later.
Your first Delta table
With a session in hand, creating a table is one line. Delta is a Spark data source, so the only new thing in the sentence below is the word delta where you would have written parquet.
spark.range(0, 5).write.format("delta").save("/tmp/delta-table")
spark.range(0, 5) is a convenient one-column DataFrame with a column named id holding 0 through 4. The write creates /tmp/delta-table, puts Parquet files in it, and writes commit 00000000000000000000.json into _delta_log/.
Read it back the same way:
df = spark.read.format("delta").load("/tmp/delta-table")
df.show()
+---+
| id|
+---+
| 0|
| 1|
| 2|
| 3|
| 4|
+---+
Now overwrite it with different data. Note what "overwrite" means here, because it is not what it means in a plain Parquet folder:
spark.range(5, 10).write.format("delta").mode("overwrite").save("/tmp/delta-table")
A plain Parquet overwrite deletes files and then writes new ones, and there is a window during which the folder is wrong. A Delta overwrite writes the new files first, then commits one entry that removes the old files from the table and adds the new ones. Readers see 0–4 or 5–9, never a mixture and never an empty table. The old Parquet files are untouched on disk, which is what makes the next section possible.
The same work in SQL, which is worth learning in parallel because production pipelines in this ecosystem are heavily SQL:
CREATE TABLE delta.`/tmp/delta-table` USING DELTA AS
SELECT col1 AS id FROM VALUES 0, 1, 2, 3, 4;
SELECT * FROM delta.`/tmp/delta-table`;
INSERT OVERWRITE delta.`/tmp/delta-table`
SELECT col1 AS id FROM VALUES 5, 6, 7, 8, 9;
The backtick-path syntax, delta./path``, is how you refer to a table that has no name. Delta supports two ways of addressing a table and the distinction matters as soon as more than one person is involved:
| Path-based table | Catalog table | |
|---|---|---|
| Addressed as | delta./tmp/delta-table`` |
sales.events |
| Where the location lives | In your code | In a catalog (Hive metastore, Unity Catalog) |
| Discoverable by others | No | Yes |
| Good for | Laptops, scratch work, one-off jobs | Anything shared |
Everything in this guide works identically either way, and paths keep the examples self-contained. Treat paths as a learning convenience: a shared table belongs in a catalogue so that readers do not have to know a bucket layout to find their data.
- Run the three Python snippets above in order.
- Run
ls /tmp/delta-tableandls /tmp/delta-table/_delta_logafter each step and watch both grow. - Open
00000000000000000001.jsonin a text editor and find theaddandremoveentries.
Time travel: reading the table as it was
The files from before the overwrite are still on disk and the commits that referenced them are still in the log. So the old table is still readable. You ask for it by version number:
spark.read.format("delta").option("versionAsOf", 0).load("/tmp/delta-table").show()
That prints 0 through 4 — the table as commit 0 left it, even though the current table holds 5 through 9. In SQL the same thing reads more naturally, and you can travel by timestamp as well as by version:
SELECT * FROM delta.`/tmp/delta-table` VERSION AS OF 0;
SELECT * FROM delta.`/tmp/delta-table` TIMESTAMP AS OF '2026-10-01';
To know which versions exist, ask for the history. Every commit shows up with its version number, timestamp, the operation that produced it, and metrics about what it did:
DESCRIBE HISTORY delta.`/tmp/delta-table` LIMIT 10;
The operation column holds values like WRITE, MERGE, DELETE, OPTIMIZE, and the operationMetrics map holds counts: files added, files removed, rows written. This is the first place to look when a table's size or row count surprises you, because it tells you which job changed what and when.
Two things follow that beginners consistently get wrong, so learn them now rather than in an incident.
Timestamp travel resolves to commit times, not to your clock. TIMESTAMP AS OF finds the latest commit at or before that moment. If the table was written once last week, every timestamp from then until now returns the same version. And if you ask for a timestamp before the table existed, you get an error rather than an empty table.
Time travel is bounded by file retention, not by the log. Travel works only while the data files of that old version still exist. VACUUM, covered later, deletes unreferenced files older than a retention window that defaults to seven days. After that runs, version 0 is in the history but its data is gone, and reading it fails. History and data have separate lifetimes; delta.logRetentionDuration defaults to 30 days for the log, and delta.deletedFileRetentionDuration to one week for the files.
The practical use for an ML student is the one that makes this feature worth the whole guide: a table version is a reproducible dataset. Record the version number alongside the experiment that trained on it and your training set is pinned forever, with no copy of the data. In an MLflow run that is one parameter:
from delta.tables import DeltaTable
history = DeltaTable.forPath(spark, "/tmp/delta-table").history(1)
version = history.select("version").first()["version"]
import mlflow
with mlflow.start_run():
mlflow.log_param("training_table_version", version)
If this appeals, DVC and lakeFS solve the neighbouring problem from a different direction, versioning files and whole repositories of data rather than tables.
Two more time-travel-adjacent commands round out the picture, and both are worth knowing exist even though you will use them rarely. RESTORE TABLE tableName TO VERSION AS OF 1 makes an old version the current one again, which is the undo button after a bad write. It does not rewind the history — it adds a new commit whose contents match the old version — so the mistake remains visible in the log, which is exactly what you want in an audit. Be aware that a restore is a data-changing operation from a downstream consumer's point of view, so streaming readers of the table may fail or reprocess. And CREATE TABLE target SHALLOW CLONE source makes a metadata-only copy: a new log that points at the same data files, created in seconds regardless of table size. It is the right way to get a realistic copy of a production table to experiment on, with one caveat that bites people — because the clone references the source's files, a VACUUM on the source can delete files the clone still needs.
- Run
DESCRIBE HISTORYon your table and note every version number. - Read each version with
versionAsOfand confirm you can see your own edit history. - Ask for
VERSION AS OF 99and read the error.
Updating and deleting rows
In a plain Parquet lake, changing one row means rewriting a partition. In Delta it is a statement, because the log lets Delta swap one file for a rewritten copy atomically.
UPDATE delta.`/tmp/delta-table` SET id = id + 100 WHERE id % 2 == 0;
DELETE FROM delta.`/tmp/delta-table` WHERE id % 2 == 0;
Both work from Python too, through the DeltaTable handle. DeltaTable.forPath gives you an object representing the table itself rather than a snapshot of its rows, which is what lets you call operations that change it:
from delta.tables import DeltaTable
from pyspark.sql.functions import expr
dt = DeltaTable.forPath(spark, "/tmp/delta-table")
dt.update(condition=expr("id % 2 == 0"), set={"id": expr("id + 100")})
dt.delete(condition=expr("id % 2 == 0"))
Understand what happens underneath, because it explains the performance you will see. Delta finds the data files containing matching rows, rewrites each of those files without the deleted rows or with the updated values, and commits one entry that removes the old files and adds the new ones. The unit of rewriting is a file, not a row. So deleting one row from a 500 MB file rewrites 500 MB. This is called copy-on-write, it is why DELETE on a Delta table is far cheaper than rewriting a partition but far more expensive than a DELETE in Postgres, and it is why a predicate that touches every file is a slow DELETE.
The operation you will reach for most in real pipelines is MERGE, which is "insert the new rows, update the ones I already have" in a single atomic commit — the upsert. Here is the shape, with a small batch of new records:
from pyspark.sql.functions import col
new_data = spark.range(0, 20)
dt = DeltaTable.forPath(spark, "/tmp/delta-table")
(
dt.alias("oldData")
.merge(new_data.alias("newData"), "oldData.id = newData.id")
.whenMatchedUpdate(set={"id": col("newData.id")})
.whenNotMatchedInsert(values={"id": col("newData.id")})
.execute()
)
MERGE INTO delta.`/tmp/delta-table` AS oldData
USING newData ON oldData.id = newData.id
WHEN MATCHED THEN UPDATE SET id = newData.id
WHEN NOT MATCHED THEN INSERT (id) VALUES (newData.id);
Read the statement as three parts: a target, a source, and a condition that decides which source rows already exist in the target. WHEN MATCHED handles the ones that do, WHEN NOT MATCHED handles the ones that do not, and the whole thing lands as one commit, so a reader never sees half of an upsert.
MERGE is the operation that is most worth being careful with, for one reason: the match condition decides how much of the table Delta has to read. A condition on the join key alone forces Delta to consider every file that could contain any matching key. Mid-level goes into how to narrow that, and the one habit to start with now is to put everything you know about where the new data belongs into the condition — a date range, a partition value — and not only the key.
WHEN MATCHED UPDATE has no single answer for which version wins, and Delta raises an error rather than picking arbitrarily. De-duplicate the source before merging. This is the most common MERGE failure for beginners and it is a correctness guard, not an inconvenience.
- Run the
UPDATEandDELETEabove, checkingDESCRIBE HISTORYafter each. - Run the merge with
spark.range(0, 20)as the source and count the rows afterwards. - Now build a source with a duplicated
idand merge again. Read the error.
Schema enforcement, and letting the schema change on purpose
The table's schema lives in the log, which means the table knows what it is supposed to look like and can refuse writes that disagree. This is schema enforcement, it is on by default, and it is the feature that turns a lake into something you can trust.
Try adding a column and the write is rejected:
from pyspark.sql.functions import lit
df = spark.range(10, 15).withColumn("source", lit("api"))
df.write.format("delta").mode("append").save("/tmp/delta-table")
You get an AnalysisException reporting a schema mismatch, listing the columns the table has and the columns you tried to write. That failure is the point. In a plain Parquet folder the write would succeed and the inconsistency would surface weeks later as a confusing read error in someone else's job.
When the new column is intentional, you say so. Three ways, in increasing scope:
df.write.format("delta").mode("append").option("mergeSchema", "true").save("/tmp/delta-table")
mergeSchema is per write, which is the one to prefer: it documents the intent at the exact place the schema changes. For a merge, the equivalent is .withSchemaEvolution() on the builder, and in SQL from Delta 4.3 onwards there is MERGE WITH SCHEMA EVOLUTION. Finally, spark.databricks.delta.schema.autoMerge.enabled=true turns evolution on for a whole session, which is convenient and is also how tables acquire columns nobody meant to add. Use it rarely and never as a default in a scheduled job.
Evolution you chose
- Per-write
mergeSchemaoption - Visible in the diff of the job that changed it
DESCRIBE HISTORYshows which commit changed the schema- Additive: new nullable columns
Evolution that happened to you
- Session-wide
autoMergeleft on - Any upstream change silently becomes a table change
- Typos in a field name become permanent columns
- Nobody can say when the table gained
user_Id
Schema evolution is additive. Adding a nullable column is routine. Changing a column's type or dropping one is a bigger operation, resting on a table feature called column mapping, and it belongs at Mid-level. What matters at this level is the distinction: enforcement is the default, evolution is a thing you opt into for one write, and the log records which commit did it.
Two related guardrails are worth knowing exist, because they move validation from a downstream job into the table itself. Delta supports NOT NULL and CHECK constraints as table-level rules: a write violating them fails, so the bad row never lands. That is a different philosophy from validating after the fact with Great Expectations, and in practice teams use both — constraints for the invariants that must never be broken, a validation suite for the distributional checks a constraint cannot express.
- Append a DataFrame with an extra column and read the rejection carefully.
- Retry with
mergeSchemaand confirm the column appears, with nulls for the old rows. - Run
DESCRIBE HISTORYand find the commit whose operation changed the schema.
Streaming in and out of a table
A Delta table can be a streaming sink and a streaming source, and both use the ordinary Spark Structured Streaming API. This matters more than it first appears: it means one table can serve a batch dashboard and a streaming consumer without two copies and two pipelines.
Writing a stream into a table needs one extra thing, a checkpoint location, where Spark records how far the stream has got:
(
spark.readStream.format("rate").load()
.selectExpr("value as id")
.writeStream.format("delta")
.option("checkpointLocation", "/tmp/checkpoint")
.start("/tmp/delta-table")
)
The rate source is a built-in generator that emits rows on a timer, which makes it ideal for learning: no Kafka, no files to produce. Each micro-batch becomes one Delta commit, so a streaming table's history grows steadily, one version per batch.
Reading a stream out of a table is the mirror image. Delta uses the log to work out which files are new since the last batch, so a streaming reader is incremental without you tracking anything:
(
spark.readStream.format("delta").load("/tmp/delta-table")
.writeStream.format("console")
.start()
)
The checkpoint location is the piece of streaming state people misunderstand, so be precise about it. It belongs to the query, not to the table. Two different streaming queries must have two different checkpoint locations. Point two queries at the same one and Delta raises a ConcurrentTransactionException, because each is trying to own the same stream identity. Equally, deleting the checkpoint directory does not reset the table, it resets the stream's memory of where it was, which usually means reprocessing everything.
UPDATE, DELETE, MERGE or RESTORE on the source table rewrites files, and the stream fails rather than silently giving you rows twice or not at all. That failure is a design decision: Delta would rather stop than lie about an incremental feed. Handling it properly, with the Change Data Feed, is Mid-level material.
Spark's own streaming machinery — triggers, watermarks, output modes — is the larger topic here, and the Apache Spark guide is the right place for it. What belongs in this guide is the shape: a table, a checkpoint per query, one commit per micro-batch.
- Start the rate-source stream, let it run for thirty seconds, then stop it.
- Run
DESCRIBE HISTORYand count how many versions appeared. - Start the console reader against the same table and watch batches arrive.
Keeping the table healthy: OPTIMIZE and VACUUM
Two housekeeping jobs are the difference between a Delta table that stays fast and one that quietly degrades. Both exist because of things you have already seen: writes add files, and rewrites leave old files behind.
Small files slow reads down. Every streaming micro-batch, every small append, every rewrite adds Parquet files. A table with 200,000 tiny files spends most of a query's time opening files rather than reading data. OPTIMIZE fixes that by rewriting many small files into fewer large ones — a compaction. It changes no rows, only their packaging:
OPTIMIZE events WHERE date >= '2017-01-01';
OPTIMIZE events ZORDER BY (eventType);
dt.optimize().executeCompaction()
dt.optimize().where("date='2021-11-18'").executeZOrderBy("eventType")
The WHERE clause restricts compaction to part of the table, which is how you keep the job's cost bounded: compact yesterday's partition nightly rather than the whole table. ZORDER BY goes further, co-locating rows with similar values of a column in the same files so that queries filtering on it can skip more files. Reach for Z-ordering on the columns you actually filter by, and know that it rewrites data and is not idempotent — running it twice does more work, it does not confirm the first run.
Since Delta 3.1.0 two settings can do a lighter version of this automatically, so you are not relying on a nightly job: delta.autoOptimize.optimizeWrite makes writers produce larger files in the first place, and delta.autoOptimize.autoCompact compacts small files right after a write.
Old files cost storage. Every file that OPTIMIZE, UPDATE, DELETE and MERGE replaced is still on disk, unreferenced by the current version. VACUUM deletes unreferenced files older than a retention window:
VACUUM events DRY RUN;
VACUUM events;
VACUUM events RETAIN 168 HOURS;
Three facts about VACUUM to get right, because this is the one command that can lose you data:
It is not automatic. Nothing vacuums your table until you schedule it, which is why a Delta table's storage can be many times its logical size.
Its default retention is seven days, and that number is a safety margin with two jobs. It keeps time travel working for a week, and it protects long-running readers: a query that pinned a snapshot an hour ago is still reading files the current version no longer references, and vacuuming them out from under it produces a missing-file error. Delta will refuse a retention below 168 hours unless you disable spark.databricks.delta.retentionDurationCheck.enabled. Do not disable it to save storage. The check exists because somebody lost data.
It deletes data files, not log files. Log retention is separate, governed by delta.logRetentionDuration at 30 days by default. So after a vacuum you can have history entries whose data is gone — the version is listed, reading it fails. Expect that and it is not confusing.
Run DRY RUN first, always. It prints the files that would be deleted without deleting them, which is a thirty-second check against a mistake you cannot undo.
- Run
DESCRIBE DETAILon your table and notenumFilesandsizeInBytes. - Run
OPTIMIZE, thenDESCRIBE DETAILagain, and compare the file count. - Run
VACUUM ... DRY RUNand read the list of files it would remove. Do not run it for real on a table whose history you still want.
Configuration: table properties and session settings
Delta is configured in two places, and knowing which is which saves a lot of confusion.
Table properties live in the table's log and travel with it. Any engine reading the table sees them. They are named delta.* and you set them at creation or with ALTER TABLE:
ALTER TABLE events SET TBLPROPERTIES (
'delta.logRetentionDuration' = 'interval 30 days',
'delta.deletedFileRetentionDuration' = 'interval 1 week'
);
The defaults worth knowing by heart, because each one explains a behaviour you will meet:
| Property | Default | What it governs |
|---|---|---|
delta.appendOnly |
false |
Set true to forbid updates and deletes outright |
delta.logRetentionDuration |
interval 30 days |
How long history entries are kept |
delta.deletedFileRetentionDuration |
interval 1 week |
The VACUUM safety window |
delta.dataSkippingNumIndexedCols |
32 |
How many leading columns get statistics for file skipping |
delta.enableChangeDataFeed |
false |
Whether row-level changes are recorded for consumers |
delta.minReaderVersion |
1 |
The oldest reader that can read this table |
delta.minWriterVersion |
2 |
The oldest writer that can write to it |
Session settings are Spark configuration, named spark.*, and they affect only the job that sets them. spark.databricks.delta.schema.autoMerge.enabled and spark.databricks.delta.retentionDurationCheck.enabled from earlier sections are both of this kind. The databricks in the name is historical — Delta Lake came out of Databricks — and these are ordinary open-source Delta settings.
The last two rows of that table deserve a paragraph, because they are the mechanism behind an error that stops people cold. minReaderVersion and minWriterVersion, together with a set of named table features, describe what a client must understand to use the table. Enabling a newer capability raises them, and from then on older clients refuse the table with an unsupported-feature error rather than reading it incorrectly. That is the right behaviour and it is also a one-way door in practice: upgrade the readers before you upgrade the table, not after.
DESCRIBE DETAIL gives the current shape: location, format, file count, total size, partition columns, and the protocol versions. DESCRIBE HISTORY gives the story: who changed it, with what operation, and what the operation did. Run both before you theorise.
- Run
DESCRIBE DETAILand findminReaderVersionandminWriterVersionon your table. - Set
delta.appendOnlytotruewithALTER TABLE, then try aDELETE. - Set it back to
false.
The errors you will actually hit
Most Delta errors fall into four families. Learn to recognise the family and the fix is usually immediate.
The jar or the configuration is missing. If a write fails saying the data source delta cannot be found, the delta-spark jar is not on the classpath: you are running a Spark that does not have it, or --packages failed to resolve. If instead the write works but a Delta-specific statement fails with a condition name mentioning that the session must be configured with the DeltaSparkSessionExtension and the DeltaCatalog, the jar is there and the two --conf settings are not. Those two errors look similar and have different fixes, which is why the setup section had you cause the second one on purpose.
The schema disagrees. An AnalysisException reporting a schema mismatch on write means enforcement did its job. Compare df.printSchema() with spark.read.format("delta").load(path).printSchema(), find the difference, and decide: is this a bug in the producer, or an intentional change that deserves mergeSchema? Those are the only two answers, and guessing is what creates mystery columns.
It is not a Delta table. An error saying the path is not a Delta table almost always means one of three things: a typo in the path, a directory that holds plain Parquet with no _delta_log/, or a path one level off — the table root is the directory containing _delta_log/, not its parent and not a partition subdirectory. ls the path and look for _delta_log/. For an existing Parquet dataset, CONVERT TO DELTA parquet./path`` creates the log in place without rewriting the data.
Two writers collided. Delta uses optimistic concurrency: writers do not take locks, they prepare a commit and then check, at commit time, whether another writer changed something they relied on. If so, the loser gets a specific exception, and the exception name tells you what happened:
| Exception | What happened |
|---|---|
ConcurrentAppendException |
Another operation added files to a partition your operation read |
ConcurrentDeleteReadException |
Another operation deleted files that your operation read |
ConcurrentDeleteDeleteException |
Another operation deleted a file yours is also deleting — classically two compactions |
MetadataChangedException |
A concurrent ALTER TABLE or schema change landed |
ConcurrentTransactionException |
Two streaming queries share a checkpoint location |
ProtocolChangedException |
A concurrent create, replace, or protocol upgrade |
The useful structure here is that pure appends cannot conflict with each other, and compaction cannot conflict with an append — Delta knows those are logically compatible. Conflicts come from operations that read before they write: UPDATE, DELETE and MERGE can conflict with almost anything. The three fixes, in order of preference, are to make concurrent writers' predicates disjoint so they genuinely touch different partitions, to serialise the operations that must not overlap such as compaction jobs, and to retry.
Finally, two version errors. A NoSuchMethodError or NoClassDefFoundError from a Delta class is almost always a Spark and Delta version mismatch — check the compatibility table, not your code. And a message about unsupported reader table features means the table's protocol is newer than your client: upgrade the client, since the table will not go back.
- Point a read at a directory with no
_delta_log/and read the error. - Start two Python sessions and run a
DELETEin each against the same table at the same time. One may fail. - For each error you produced, write down the family it belongs to.
Putting it all together
One small end-to-end project, using everything above. The scenario is a daily feed of customer orders landing as a batch, which you upsert into a Delta table and then train on a pinned version — the shape of most lakehouse work you will be asked to do.
import pyspark
from delta import configure_spark_with_delta_pip
from delta.tables import DeltaTable
from pyspark.sql.functions import col
builder = (
pyspark.sql.SparkSession.builder.appName("Orders")
.config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
.config(
"spark.sql.catalog.spark_catalog",
"org.apache.spark.sql.delta.catalog.DeltaCatalog",
)
)
spark = configure_spark_with_delta_pip(builder).getOrCreate()
PATH = "/tmp/orders"
# Day one: create the table from the first batch.
day_one = spark.createDataFrame(
[(1, "cairo", 120.0), (2, "riyadh", 80.0), (3, "dubai", 240.0)],
"order_id int, city string, amount double",
)
day_one.write.format("delta").mode("overwrite").save(PATH)
# Day two: one corrected amount and one new order. Upsert, not append.
day_two = spark.createDataFrame(
[(2, "riyadh", 95.0), (4, "amman", 60.0)],
"order_id int, city string, amount double",
)
dt = DeltaTable.forPath(spark, PATH)
(
dt.alias("t")
.merge(day_two.alias("s"), "t.order_id = s.order_id")
.whenMatchedUpdate(set={"amount": col("s.amount")})
.whenNotMatchedInsertAll()
.execute()
)
# Pin the version that a model would train on.
training_version = dt.history(1).select("version").first()["version"]
print("training on version", training_version)
spark.read.format("delta").option("versionAsOf", training_version).load(PATH).show()
spark.sql(f"DESCRIBE HISTORY delta.`{PATH}`").show(truncate=False)
Run it and read the history output, because that is where the lesson is. You will see a WRITE at version 0 and a MERGE at version 1, with metrics showing rows updated and rows inserted. The table now holds four orders, one of which has a corrected amount, and version 0 still holds the uncorrected three. Nothing was lost and no reader ever saw a partial state.
Three extensions, each exercising one more section of this guide. Add a CHECK constraint that amount is positive and watch a bad batch get rejected at the table rather than in a dashboard. Add a column to day three's batch and do it twice, once without mergeSchema to see the rejection and once with it. Then run DESCRIBE DETAIL, OPTIMIZE, and VACUUM ... DRY RUN and read what each tells you about the cost of the history you have accumulated.
If you want the table to be more than a path on your laptop, the natural next step is to register it in a catalogue so colleagues can find it by name, and to run the pipeline on a schedule with Airflow or Prefect rather than by hand.
- Run the script end to end and read the
DESCRIBE HISTORYoutput line by line. - Add the
CHECKconstraint and a batch that violates it. - Add a day-three batch with a new column, once without and once with
mergeSchema.
What you can now do, and what comes next
You can explain why a folder of Parquet is not a table and what the transaction log adds, install a matched Spark and Delta pair and configure a session correctly, create and read a Delta table, overwrite it atomically, read any earlier version by number or timestamp, update and delete rows, upsert with MERGE, let the schema change on purpose while enforcement blocks accidents, stream in and out of a table, compact it with OPTIMIZE, reclaim storage with VACUUM without destroying your history, read its properties and protocol versions, and classify the four families of Delta error on sight.
| Can you… | |
|---|---|
| Say what makes a directory a Delta table? | A _delta_log/ folder of commits |
| Explain why a failed write is invisible? | The commit lands last, files before it are unreferenced |
| Explain snapshot isolation? | A reader pins the file list it started with |
| Name the two configs Delta needs? | spark.sql.extensions and spark.sql.catalog.spark_catalog |
| Read last week's version of a table? | versionAsOf, or VERSION AS OF |
| Say what limits how far back you can travel? | File retention, so VACUUM, not the log |
Say why DELETE of one row is expensive? |
Files are rewritten whole |
| Add a column safely? | mergeSchema on that one write |
| Say why two streams must not share a checkpoint? | ConcurrentTransactionException |
| Fix a table that got slow after a month of streaming? | OPTIMIZE, plus auto compaction |
Say why VACUUM refuses a two-hour retention? |
It would break time travel and live readers |
| Tell a conflict exception from a schema error? | The exception family names the cause |
Mid-level takes every one of these further: how the log is replayed and checkpointed in detail so you can predict a query's planning cost, data skipping and the statistics that drive it, MERGE performance and how the match condition determines how much gets read, Change Data Feed for incremental consumers, partitioning versus clustering, constraints and generated and identity columns, converting existing Parquet and Iceberg tables in place, shallow clones for safe experiments, and running all of it on S3, ADLS and GCS with the right LogStore.
Senior then covers what you own when Delta is a platform for other teams: the protocol and table features as a compatibility contract, multi-cluster write safety and the failure modes that lose data silently, retention and cost policy across hundreds of tables, which component breaks first as tables and writers multiply, catalogue and storage-layer access control including data-residency constraints for Gulf and Egyptian employers, upgrade and migration plans across Spark major versions, and the incident playbooks for a corrupted or over-vacuumed table.
The nearest neighbours in this catalogue are Apache Spark for the engine underneath, Databricks for the managed platform where most Delta tables actually live, lakeFS and DVC for versioning at the file and repository level instead of the table level, and Great Expectations for the data-quality checks that constraints cannot express.