a folder · a ledger · five calls that change both

The Delta table API: a folder of files that behaves like a table

There is no database server anywhere in this picture. There is a folder, some files inside it, and a written record of which files count right now. Everything the API does is one of those two things: write a file, or write a line in the record.

version 0 · 1 file live/lake/orders

Before anything else: a table here is a folder, and the folder is just files

Start with the physical situation, because every confusing thing later comes from people describing the logical situation as though the physical one did not exist.

You have data. Say a list of orders. Each order is a row, meaning one complete record about one thing, written as a line of values that belong together. Each thing you record about that order is a column, meaning one named field that every row has: an order number, a customer, a country, an amount. Rows and columns together make a table, which in the ordinary sense of the word is a rectangle of values with named columns.

Now, where does that rectangle physically live? In the systems this piece is about, it does not live inside a running program that owns it. It lives in a folder on cheap storage, the same kind of folder you have on a laptop, usually rented from a cloud provider. Inside that folder are data files, each one holding some of the rows. Not all of them. Some.

The figure below is that folder. Each rectangle is one file, and the number written on it is how many rows are inside that file, because a file's row count is the only thing about it that matters for now. Click a file to see which orders it holds.

Figure 1 · the folder, openedclick a file
Click any file rectangle. Its rows appear on the right. The table you think of as one thing is the rows of all these files, read together.

That is the whole physical setup, and it is worth sitting with for a moment because two things follow from it immediately.

First, reading the table means reading several files and stacking their rows. There is no other step. Nobody consults an index, nobody asks a server. A program opens the files it has been told to open, and the rows it gets are the table.

Second, and this is the uncomfortable one: nothing in that folder says which files are part of the table. The folder is a folder. If somebody drops a stray file in, it is now apparently part of your data. If a program crashes halfway through writing, whatever it managed to write is sitting there looking exactly like a real file. The folder has no opinion.

picture it

A filing cabinet drawer with no index card at the front. Everything in the drawer is presumably the accounts, because it is in the accounts drawer. If a colleague was interrupted mid-filing and left half a batch in there, those pages are in the drawer too, and they look like every other page.

You could work like this, and people did for years. It works right up until two people use the drawer at the same time, or one of them is interrupted. The drawer has no way to say "these pages count and those do not", and that single missing sentence is what everything in this piece is built to provide.

So the folder gives you storage and nothing else. To see why that is not enough, it helps to watch it fail at something completely ordinary.

Watch a plain folder fail at one ordinary job

Here is the job: today's orders have arrived, and you want to add them to the table. In the physical picture, that means writing a new file into the folder. The word for that is a write, meaning any operation that changes what is stored.

Adding one file sounds safe. But writing a file is not instantaneous: a big file takes time, and during that time the file exists and is incomplete. Meanwhile somebody else, perhaps a dashboard refreshing on a timer, is a reader, meaning any program opening those files to get the rows out. It has no idea a write is in progress, because nothing tells it.

In the figure, the writer's progress is drawn as a file that fills up over time, and the reader is drawn as an eye that opens at a moment you choose. Press the two buttons in either order and see what the reader gets.

Figure 2 · a reader arriving mid-writenothing happening
Start the write, advance it partway, then read. The count the reader gets is shown against the count it should have got.

Read during the write and you get a number that was never true: yesterday's rows plus part of today's. Nothing failed. No error was raised. The dashboard shows a figure, somebody screenshots it for a meeting, and the number is simply wrong, in a way that is impossible to detect afterwards because five minutes later the file finished and everything looks fine.

The property missing here has a name worth learning now, because it is the whole point of the machinery that follows. An operation is atomic when, from the point of view of anybody looking, it has either completely happened or not happened at all, with no observable in-between. The write above is the opposite: its in-between is fully visible, and readers land in it.

Now try the second job, which is worse. Change one row: order 1043 was recorded with the wrong amount. The rows live inside a file, and a file cannot be edited in place on this kind of storage. To change one row you must write a new version of the whole file and get rid of the old one, and between those two acts, the folder contains either both copies or neither.

picture it

Correcting a typo on one page of a bound report by retyping the whole page. There is a moment when both the old page and the new page are on the desk, and a moment when neither is in the binder. If somebody photocopies the binder during either moment, they get a document that never officially existed: one with the page twice, or one missing it.

The fix is not to type faster. It is to have a contents page that says which pages the report currently consists of, and to update that page in one stroke. The loose pages can sit around as long as they like, as long as the contents page never lists a page that is not finished.

That contents page is the entire idea. It has a name, it is a real file on disk, and everything else in this piece is either writing one or reading one.

The log is a numbered list of which files count, and it is written last

Inside the folder, alongside the data files, sits a subfolder named _delta_log. It contains small numbered files: 00000000000000000000.json, then ...001.json, and so on. Together they are the transaction log, meaning an append-only record of every change ever made to the table, where append-only means new entries are added at the end and existing entries are never edited.

Each numbered file records one commit, meaning one completed change, described as a list of data files added and data files removed. That is genuinely most of what is in there. Not the rows. Just: these files joined the table, these files left it.

The state of the table after commit number n is called version n. Version 0 is the table after the first commit, version 1 after the second, and so on. A folder with a log like this, read through the log rather than directly, is a Delta table.

Here is the order of events that makes it work, and it is the one detail to hold onto. The data file is written first, completely, while nobody is looking at it, because nothing in the log mentions it yet. Only when the file is finished does the writer append a log entry naming it. Readers consult the log, so the file becomes part of the table at the instant that one small entry appears, and not a moment before.

Figure 3 · the same write, with a logstep 0 of 4
Advance the write step by step and read at any point. On the left is the folder as it physically is; on the right is what a reader following the log sees. They deliberately disagree during the write.

Advance the write halfway and read: the reader still gets yesterday's count, exactly and correctly, because the half-written file is not named in any log entry and so is not part of the table. Advance to the commit and read again: the number jumps in one go. There is no moment at which a reader sees a partial answer. The write has become atomic, and nothing about the storage changed to achieve it. What changed is that the definition of the table moved from "whatever is in the folder" to "whatever the log says".

Once you see that, the rest of the design follows almost mechanically. Updating a row means writing a new data file with the corrected row, then committing one entry that says: add the new file, remove the old one. Both halves land together or neither does. Deleting rows is the same shape. So is everything else.

picture it

A theatre programme printed fresh for each night's performance. The cast are people, the programme is a list. Swapping an understudy in does not involve editing a person; it involves printing a new programme. Anyone holding tonight's programme has a coherent list, even though the building is full of last week's programmes and next week's drafts.

The log is the programme, the data files are the cast, and the reason a reader never sees a half-finished cast change is that they were handed a programme that was complete when it was printed.

All of this is a description of files on disk. What you actually type is a different question, and that is where the word API starts to matter.

An API is the set of calls you are allowed to make, and Delta gives you two

An API, short for application programming interface, is the list of operations a piece of software offers to programs that use it, along with what each one expects and returns. It is the menu. It is not the kitchen.

You will meet the Delta table API in two forms, and confusion between them costs beginners a lot of time, so here they are side by side.

The first form is SQL, where you write a statement as text and the engine works out what to do. The second is the programmatic API, where your code obtains an object representing the table and then calls methods on it. A method is just a function that belongs to an object, written after a dot: table.delete(...). Both forms reach the same files and the same log. Neither is more real than the other.

Two more words before the code, both of which appear in every example you will ever read. A path is the address of the folder, written as a string, like /lake/orders. And a DataFrame is the in-memory representation of a set of rows and columns that your program is currently working with: the rectangle, loaded, sitting in the program, ready to be filtered or counted or written somewhere. A DataFrame is not the table. It is a copy of some rows from it, held in memory.

Figure 4 · the same job, four wayscount the rows
Pick a job. The SQL form and the Python form appear together, with the files and log entries each one produces shown underneath, so you can see that both routes end in the same place.

The programmatic route needs one more idea, and it is the piece of vocabulary that makes most Delta documentation readable. Before calling methods on a table, your program needs an object standing for it. That object is a handle: a value in your program that refers to something outside your program, holding the address and knowing how to reach it, rather than holding a copy of the contents.

You obtain one like this, and the name of the call says exactly what it does, which is get me a handle for the Delta table living at this path:

from delta.tables import DeltaTable dt = DeltaTable.forPath(spark, "/lake/orders") # a handle, not the data # the handle knows how to hand you the rows, as a DataFrame df = dt.toDF() df.count() # and how to describe itself dt.history(5).show() # the last five commits dt.detail().show() # size, file count, location, properties

Note what dt is not. It is not the rows: creating it reads a little metadata and no data. It is not a snapshot frozen in time either; ask it for rows again after somebody else commits, and you get the newer state. It is a way of addressing the table, which is why almost every operation in the rest of this piece starts by getting one.

picture it

The difference between a library card with a shelf reference on it and an armful of books. The card is small, you can carry it around, and it does not go stale when the shelf changes. When you actually want to read something you take the card to the shelf and come back with the books.

The handle is the card. The DataFrame is the armful of books. Confusing them is what makes people ask why their table did not change when they modified their DataFrame: they edited the copy in their arms, not the shelf.

With a handle in hand, the simplest thing to do with a table is put rows into it.

Writing rows: two modes, and the one that quietly deletes everything

Writing is done through the DataFrame, not the handle. That surprises people, and the reason is simple once said: you are asking a set of rows to save itself somewhere, so the rows are the sensible starting point. The call chain reads left to right as a sentence.

(new_orders # a DataFrame: the rows you want to save .write # give me a writer for these rows .format("delta") # write them as a Delta table, not plain files .mode("append") # add to what is there .save("/lake/orders")) # at this path

The mode is the part to slow down on, because it decides what happens to rows that are already in the table, and the two common values do opposite things.

append means add these rows and leave everything else alone. On disk: one new data file, one commit entry adding it, nothing removed. overwrite means this DataFrame is now the entire contents of the table. On disk: new data files, and a commit entry that removes every file that was live before.

In the figure, the folder is drawn as before, and each commit is drawn as a row in the ledger showing what it added and what it removed, because add and remove are the only two things a commit ever contains. Run the same DataFrame in both modes and compare the ledger.

Figure 5 · append against overwriteappend
Write in append mode a few times, then reset and try overwrite. Watch the removed column of the ledger, which is empty for every append and full for the overwrite.

An overwrite with the wrong DataFrame is the classic way to lose a table, and it is worth being precise about what "lose" means here, because it is less bad than it sounds. The old data files are removed from the table, meaning the newest commit no longer lists them. They are still sitting in the folder. The table at the previous version still names them and can still be read. Recovery is possible, for a while, and there is a call for it later in this piece.

One more term arrives with writing, unavoidably. The schema of a table is its column names and the type of each one: order_id is text, amount is a decimal number, and so on. Delta records the schema in the log and checks every write against it. Try to append rows with a column the table does not have, or with text where numbers belong, and the write fails rather than landing silently.

That refusal is a feature, and it takes people by surprise the first time. The alternative, which plain file folders offer, is that mismatched data lands happily and is discovered months later by somebody whose report is producing nonsense. If the new column really is wanted, you say so explicitly:

(new_orders.write.format("delta").mode("append") .option("mergeSchema", "true") # yes, really add the new column .save("/lake/orders"))
picture it

A form with fixed boxes. Append is handing in another completed form to add to the pile. Overwrite is throwing out the pile and putting yours on the empty desk. Schema enforcement is the clerk refusing a form with an extra box drawn on it in pen, until somebody in charge agrees the form has genuinely changed.

The clerk is not being obstructive. Every form in the pile is supposed to have the same boxes, and that promise is the only reason anyone can add the pile up.

Adding rows is the easy half. The interesting calls are the ones that change rows already in the table, and to follow those you need to see what they do to files.

Changing one row rewrites a whole file, and that explains almost everything

Order 1043 has the wrong amount. In a database of the kind that runs as a server, you would tell it to change that row and it would edit the row where it sits. Here you cannot, and the reason is the storage: files of this kind are written once and are not editable in the middle. To change one row inside a file, the only physical option is to produce a new file containing all of that file's rows with the one row altered, and stop counting the old file as part of the table.

That approach has a name, copy-on-write, meaning the system copies the affected unit and writes the change into the copy, rather than modifying the original. The unit being copied is the file, which is why a one-row change can rewrite a file holding a million rows.

The call itself is short. You call it on the handle, and you give it a predicate, meaning a condition written as text that says which rows you mean, in the same style you would write after the word WHERE in a query.

from delta.tables import DeltaTable from pyspark.sql import functions as F dt = DeltaTable.forPath(spark, "/lake/orders") dt.update( condition = "order_id = '1043'", # the predicate: which rows set = {"amount": F.lit(24.90)}) # the new value dt.delete("order_date < '2024-01-01'") # same shape, no set

In the figure, each file is drawn as before with its row count, and the rows inside the file you are changing are drawn individually so you can see which one moved. Files the engine did not need to touch stay pale. The one it rewrote is drawn twice: the old version faded and marked removed, the new one solid and marked added, because that pair is exactly what the commit contains.

Figure 6 · one row changed, one file rewrittennothing changed yet
Try the three operations. Each shows how many files were read, how many were rewritten, and how many rows were actually changed, so the ratio between work done and rows affected is visible.

Press the third button and the cost becomes obvious: an update touching rows in every file rewrites every file. The rows changed might be a hundred, and the rows rewritten a hundred thousand, because rewriting is done in whole files.

This single fact explains a run of behaviours that otherwise look arbitrary. It explains why a targeted update on a well-organised table is fast and the same update on a badly organised one is slow: the difference is how many files contain a matching row. It explains why these systems reward writing predicates that line up with how the data is laid out. And it explains why deleting a small number of rows can produce a surprisingly large amount of new storage.

picture it

A wall of printed volumes where you cannot write in the margins. Correcting one sentence means reprinting the volume that sentence is in, and shelving the new copy in place of the old one. Correcting one sentence in every volume means reprinting the entire wall.

Nothing about the correction got harder. What varies is how many volumes the correction is spread across, which is a fact about how the text was bound, not about the correction.

Which leaves the question that decides whether any of this is quick or ruinous. When you ask for the German orders, how does the engine know which files hold German orders? On the evidence so far it does not, and it does the only thing it can.

Partitioning writes the filter into the folder names

Ask the folder for the German orders. You have three files and nothing else. Which one do you open?

All three. You have no choice. Nothing about the name part-0000 tells you whether there is a German order inside it, so the only way to find out is to open it and look. Three files is fine. Thirty thousand files, which is an ordinary size for a table that has been appended to every hour for a year, is not fine at all, and no amount of clever querying helps, because the cost was fixed the moment the rows were written.

The fix is almost insultingly simple: put the answer in the folder name.

/lake/orders/ country=DE/part-0000.parquet # every German order, and nothing else country=GB/part-0000.parquet country=US/part-0000.parquet

That is partitioning. You nominate one or more columns as partition columns, and the writer sorts every row into a subfolder named for its value in those columns. No row is duplicated and no index is built. The value simply moves out of the file and into the path.

The payoff arrives at read time. Asked for Germany, the engine lists the directory names, sees that two of the three cannot possibly contain a German row, and discards them without opening a single file. That step has a name, partition pruning, and it is the difference between a query that reads three files and one that reads thirty thousand.

You choose the layout once, at write time, and it is part of the table from then on.

(df.write .format("delta") .partitionBy("country") # the layout, fixed at write time .mode("append") .save("/lake/orders")) spark.read.format("delta").load("/lake/orders") \ .where("country = 'DE'") # prunes: opens one folder spark.read.format("delta").load("/lake/orders") \ .where("order_month = '2024-03'") # prunes nothing: opens everything

Those last two lines are the whole lesson, and the figure below is built to make the difference impossible to miss. Change the layout, change the question, and watch which files the engine actually has to open.

Figure 7 · the layout decides what can be skippedno partitioning · all orders
Pick a layout, then pick a question. Solid folders are opened and read; faded ones are discarded on their name alone. The counter underneath is the only number that matters, because reading a file is the thing you pay for.
picture it

A filing room. In the first version every drawer is unlabelled and they are stacked to the ceiling, so finding the German invoices means opening every one. In the second, the drawers are labelled by country, and finding the German invoices means walking past two of them without touching either.

The paper did not change and neither did the question. What changed is that the label now answers part of it, and a label you can read from across the room costs nothing to check.

Now try the layout by country with the question month = '2024-03', because that combination is where most real disappointment lives. The drawers are labelled by country and the question is about March. The labels say nothing about months, so not one drawer can be ruled out, and the engine opens all of them. Partitioning bought exactly nothing.

Pruning is not a property of the table. It is a property of the match between how the data was laid out and what you happened to ask.

Which points at the obvious temptation: partition by more columns, prune more questions. The third layout does that, and it works, and the counter shows the cost. Twelve orders now sit in six folders, so the average file holds two rows. Scale that up and it is the same shape of problem either way: partition by customer and a table with fifty thousand customers gets fifty thousand folders, most holding a file of a few kilobytes. The engine now spends its time listing directories and opening tiny files, and the pruning it gained is dwarfed by the overhead it bought. The number of distinct values in a column is its cardinality, and partitioning on a high-cardinality column is the single most common way to make one of these tables slow.

So the rule is not "partition more", it is "partition by the thing you filter on, and only while the folders stay big". A commonly cited target is at least about a gigabyte of data per partition. Country works. Order date by month often works. Order id never works.

One reassurance, because the picture so far is bleaker than reality. Even with no partitioning at all, Delta records the minimum and maximum value of each column in every file, in the log. A query for order 1043 can therefore skip files whose recorded range does not contain 1043, without any folder structure at all. That is data skipping, it is on by default, and it is why the honest advice for a modern table is to partition sparingly, or not at all, and let the statistics do the work.

Update and delete each do one job. The call most people come to Delta for does three at once, and it is worth walking through slowly.

Merge: bring me today's file and I will work out what changed

Real feeds do not arrive sorted into things to add and things to change. A supplier sends today's file, and inside it are some orders you have never seen, some you have seen with corrections applied, and possibly some marked cancelled. Doing that with the calls so far means three passes and some awkward bookkeeping between them.

Merge is one call that does all three. Two words come with it and they are used constantly in the documentation. The target is the existing table being changed. The source is the incoming rows you are merging in. You supply a condition saying how a source row is recognised as being about the same thing as a target row, and then a clause for each case.

Follow one merge through, one clause at a time.

Figure 8 · one merge, clause by clausethe two sides
Click any step to jump to it. Target rows are on the left, incoming source rows on the right, and the colour of a row shows which clause claimed it.

Two sets of rows, and one question about each pair

Four rows already in the table, three arriving today. The question asked of every possible pairing is the merge condition, and here it is simply whether the order ids are equal.

Some source rows find a partner

Order 1043 exists on both sides. In merge vocabulary that pairing is matched, and it is the case where the table already knows about this thing and the incoming row is presumably newer information.

Matched rows take the update clause

The clause says what to do about a match: usually overwrite the target row with the source row's values. Physically this is the file rewrite from a moment ago, done for you.

Some source rows find nobody

Order 1102 has never been seen. That is not matched, and the natural response is to insert it. Together, update-if-present and insert-if-absent is what people mean by the word upsert.

And some target rows are never mentioned

Order 1041 is in the table and not in today's file. There is a third clause for that case, and its default is to do nothing, which is almost always right: absence from one day's file is not evidence of anything.

All of it lands as one commit

Files rewritten, files added, one entry in the log. A reader either sees the whole merge or none of it, which is the atomicity from earlier doing its job on an operation with three moving parts.

Written out, the call mirrors the walk-through exactly. Read it as a sentence: for this target, merge that source, matching on this condition, and here is what to do in each case.

(DeltaTable.forPath(spark, "/lake/orders").alias("t") .merge( source = todays_file.alias("s"), condition = "t.order_id = s.order_id") # how a pair is recognised .whenMatchedUpdateAll() # it exists: take the new values .whenNotMatchedInsertAll() # it is new: add it .execute())

Two details on that snippet, both of which trip people up. alias gives each side a short name so the condition can say which side it means, which is why you see t. and s. prefixes everywhere in merge examples. And nothing happens until execute(): the calls before it are describing the operation, not performing it.

Each clause can also carry its own condition, which is how you express the rule that stops replays from doing damage:

.whenMatchedUpdateAll(condition = "s.updated_at > t.updated_at")

Without it, re-running an old file overwrites newer corrections with stale values. With it, a merge can be run twice, or out of order, and the result is the same, which matters because pipelines are re-run constantly.

picture it

A membership secretary with a card index and a stack of forms handed in this week. For each form: find the card, update it if it exists, write a new card if it does not, and leave alone the members who did not hand anything in. One person, one pass, three different actions depending on what they find.

Merge is that pass, expressed as one instruction instead of three, and committed as one change instead of three.

Every operation so far has produced a new version and left the old files sitting in the folder. That accumulated history is not waste. It is a feature you can read.

Every past version is still readable, because its files are still named

Here is the dial for this piece, and it is the capability that most surprises people coming from ordinary databases.

A commit records which files were added and removed. Work through the log from the start and stop at commit 3, and you have the exact list of files that made up the table at that moment. Read those files and you have the table as it was. That list is a snapshot, meaning the complete contents of the table at one version, and reading one is called time travel.

It costs nothing to keep, because nothing was deleted to create the new version. The old files were unlisted, not removed.

In the figure, drag the version slider along the log. The top strip is the log with the selected commit marked. The middle is the folder, where files live at the selected version are drawn solid and files not in that version are drawn faded, so you can watch the same physical folder produce different tables. The bottom is the rows a reader gets. Everything is computed from the commits above it.

Figure 9 · the same folder at every versionversion 5 of 5
v0 first appendv2 the updatev3 the deletev5 now
Drag the slider from version 5 back to version 0. The folder never changes. What changes is which of its files the log says are part of the table.

Drag to version 1 and the row corrected at version 2 shows its old amount again. Drag to version 2 and the rows deleted at version 3 are back. This is not a backup being restored: it is the ordinary read path, pointed at an earlier list.

You ask for it with one option, by version number or by wall-clock time:

# by version df = spark.read.format("delta").option("versionAsOf", 3).load("/lake/orders") # by timestamp: the version that was current at that moment df = (spark.read.format("delta") .option("timestampAsOf", "2024-05-07 09:00:00") .load("/lake/orders")) # in SQL, the same two spark.sql("SELECT * FROM delta.`/lake/orders` VERSION AS OF 3") spark.sql("SELECT * FROM delta.`/lake/orders` TIMESTAMP AS OF '2024-05-07 09:00:00'")

Three uses justify the whole mechanism. Reproducing a number somebody is querying, by reading the table as it stood when the report ran. Comparing two versions to see what a job actually changed, by reading both and subtracting. And undoing a mistake, which has its own call rather than requiring you to copy data around:

dt.restoreToVersion(4) # or dt.restoreToTimestamp("2024-05-07 09:00:00")

Restore is worth one extra sentence, because its behaviour is a small lesson in how the whole system thinks. It does not rewind the table by erasing versions 5 and 6. It appends a new commit whose file list happens to match version 4's. History is never rewritten, only extended, so the record of the mistake and of the recovery both remain.

picture it

A ship's logbook, where entries are added in ink and never erased. Wanting the ship's position last Tuesday does not mean undoing this week: it means reading down to Tuesday and stopping. Correcting an error means writing a correction as today's entry, not scratching out the original.

Time travel is reading down to a point. Restore is writing a new entry that says: we are back where we were on Tuesday. Neither one removes anything.

Which raises a question you may have been holding since the log first appeared. If nothing is ever deleted, does this folder simply grow forever?

Housekeeping: two calls, one of which is the only way to lose data

The answer is yes, it grows, and there are two calls that deal with it. They are usually mentioned in the same breath and they do completely different things, so it is worth keeping them apart from the start.

The first problem is not size, it is count. A pipeline appending a small batch every few minutes produces a small file every few minutes. Reading the table then means opening thousands of files, and opening a file has a fixed cost regardless of how little is in it, so a table holding a modest amount of data can become slow to read purely because of how it is spread out. That is the small files problem.

OPTIMIZE fixes it by reading many small files and writing their rows out as fewer large ones. Nothing about the data changes. In log terms it is one more commit: many files removed, a few added.

spark.sql("OPTIMIZE delta.`/lake/orders`") # with a layout hint, if queries usually filter on these columns spark.sql("OPTIMIZE delta.`/lake/orders` ZORDER BY (order_date, country)")

The second problem is the files nobody needs any more: the ones removed by earlier commits, still sitting in the folder. VACUUM deletes them from storage, and it is the only operation in this piece that actually destroys anything.

It will not delete files that any recent version still needs, and the meaning of recent is a setting called the retention period, which is the age a discarded file must reach before vacuum is allowed to remove it. The default is seven days.

spark.sql("VACUUM delta.`/lake/orders`") # default retention: 7 days spark.sql("VACUUM delta.`/lake/orders` RETAIN 720 HOURS") # 30 days

The consequence lands directly on the version slider from a moment ago, and it is the sentence people wish they had read earlier: the retention period is the real limit on how far back you can time travel. The log may still describe version 2, but if vacuum has removed the files version 2 named, reading it fails. Set retention against how far back somebody might genuinely need to reproduce a number, not against the storage bill.

In the figure, the folder is drawn with each file in one of three states, which are worth reading off before you press anything: solid means live in the current version, faded means removed from the current version but still needed by some older version you could still read, and outlined means removed and old enough that vacuum may delete it. The horizon marker shows how far back time travel currently works.

Figure 10 · what each housekeeping call touchesday 30 · 30 files
Append for a few months, then compact, then vacuum. Optimize moves the file count; vacuum moves the time-travel horizon. Neither one moves the other.
picture it

An office that keeps every superseded draft. Optimize is having the loose pages bound into proper volumes: the same words, far easier to carry. Vacuum is the shredder, run on drafts older than a stated age.

Binding is reversible in the sense that nothing was lost. Shredding is not, and the age on the shredding policy is exactly how far back anyone can ever check what a document used to say.

There is one more thing the log offers before we get to what all of this cannot do, and it is free, because it is simply the log read as a report.

dt.history().show(truncate=False) # version | timestamp | operation | operationParameters | operationMetrics ... # 5 | 09:41 | MERGE | {predicate: t.order_id = s.order_id} | {numTargetRowsUpdated: 2, ...}

Every commit records what kind of operation made it, the parameters it was given, and how many rows it touched. Nobody had to switch that on. It is a by-product of the log existing, and it answers the question that plain file folders make unanswerable: who changed this table, when, with what, and how much did it affect.

Two writers at once, and the error message that means it worked

Everything so far assumed one writer. Real systems have several: a nightly load, a correction job, somebody running a backfill by hand.

The rule for how simultaneous changes are handled is called concurrency control, and Delta uses the optimistic kind, meaning writers do not reserve the table in advance. Each one reads the current version, does its work, and then tries to commit. The commit is where the check happens: if somebody else committed in the meantime, and their change overlaps with yours, your commit is rejected.

The rejection is the mechanism working. Nothing was corrupted, and no half-merged state was written. Your job raises an error saying the table was modified concurrently, and the correct response is usually to run it again, since it will then read the new version and redo its work on top.

In the figure, two writers are drawn as two lanes, each showing which version it read and what it wants to commit. Press the buttons to advance either one, and see which commits succeed.

Figure 11 · two writers, one logboth idle
Have both writers read, then commit both. Then try the separate-partitions version, where their work does not overlap and both succeed.

Two appends to different parts of a table generally both succeed, because they add files and remove nothing, so there is nothing to disagree about. Two merges aimed at the same rows will conflict, and that is correct, since one of them was computed against a table that no longer exists.

The practical guidance follows directly: arrange for writers not to overlap where you can, and where you cannot, let the loser retry rather than catching the error and continuing. A caught conflict that is then ignored is how a job reports success while having done half its work.

picture it

A shared paper ledger where the rule is that you may only write on the next blank line, and only if the line above is the one you read when you started. Two clerks who both read line 40 and both go away to prepare an entry will find that only the faster one can write line 41. The other has to read line 41 and redo their sum.

Nobody was locked out of the room, and no half-entry was ever written. The cost of optimism is redoing work occasionally; the benefit is that readers are never blocked and writers never queue.

That is the mechanism in full. What remains is being clear about the shape of problem it is for, which is narrower than the enthusiasm around it sometimes suggests.

What this is not: a database for your application

Delta tables suit an analytical workload, meaning work that reads a great many rows at a time to compute summaries, and writes in batches. They suit it very well: large scans, columns rather than whole rows, and periodic bulk changes.

They do not suit a transactional workload, meaning the pattern behind an ordinary application, where thousands of small operations a second each touch one row and expect an answer in milliseconds. Copy-on-write is the reason: a one-row change rewrites a file. Serving a shopping basket from a Delta table would be a design error, and no amount of tuning fixes it, because the mismatch is architectural.

Three more limits deserve stating plainly rather than being discovered.

The storage underneath has its own rules. These tables usually live on object storage, meaning cloud services that store whole files addressed by name, rather than a filesystem with directories in the traditional sense. Listing files there is slower than you expect, renames are not always cheap, and the guarantees vary by provider. Most of the awkward operational corners in real deployments trace back to that layer rather than to Delta.

Small frequent writes are the expensive pattern. Every commit is a file plus a log entry. Committing every few seconds produces a table that needs constant compaction to stay readable. Batching writes is not a micro-optimisation here, it is how the design expects to be used.

Deleting for privacy requests is more work than a delete. The rows leave the current version, and they remain in older versions until vacuum passes the retention horizon. Any credible erasure process has to account for that, and for copies in other tables downstream.

The figure lays out four workloads against what each one needs, so the fit and the mismatch are visible together rather than asserted.

Figure 12 · four workloads, one table formatnightly reporting load
Step through the four. Each row shows what that workload demands and whether this design supplies it, with the reason drawn from the mechanism rather than from a rule of thumb.
picture it

A goods lift rated for pallets. It moves a tonne at a time, all day, reliably. Using it to carry single envelopes between floors is not a misuse that better scheduling fixes: the doors alone take longer than the errand.

Choosing between this and an application database is not a question of scale or of how modern you are. It is a question of whether your work arrives in pallets or in envelopes.

Which is a reasonable place to stop, with the whole mechanism in view: a folder, a log, a handful of calls that write one and append to the other, and a set of consequences that all trace back to those two facts.

Every call in this API does one of two things. It writes data files while nobody is looking, or it appends a line to a log saying which files count now. Reading, updating, merging, time travel and recovery are all consequences of those two moves.

Now try it yourself

A table you can operate. Issue calls in any order and watch both halves respond: the folder on the left, the log on the right, the rows a reader would get underneath. Nothing here is scripted, so you can put the table into states this piece never described.

the whole tableversion 0 · 1 file
read at version
Try this order: append twice, update, vacuum, then drag the version slider back. The early versions are still listed in the log and their files are gone, which is the one failure this design permits you to cause.