Skip to content
abhinavbhatia
Sheet 02 / 05 — Blogs← Archive

Databricks on Google Cloud, Prerequisite 1: What a lakehouse is: storage, table formats and Spark

From zero: data warehouses, data lakes and the lakehouse; objects, schema on write and schema on read; ACID transactions; indexing, caching and query optimisation; the medallion pattern; Parquet, the Delta Lake log, VACUUM, Apache Iceberg and UniForm; and Apache Spark. The open ideas every later part of the series builds on.

TL;DR Pocket Books · Prereq 1

Read this chapter as 5 illustrated mini-books

Flip pages horizontally like a hardcover book, or swipe up like a reel to move through all 5 topics (20 animated pages).

0 of 5 readAll Series Shorts →
Part 1 · TL;DR 1 of 5
← → Turn Page · ↑ ↓ Next TL;DR ReelSwipe ← → page · Swipe ↑ ↓ next TL;DR
100%
1.0×
Full post ↗
PART 1 · FOUNDATIONSPLATE 01.1

i
TL;DR 01PAGE 1 OF 4
MINI-BOOK 01 · STEP 1

This post is about the ideas underneath Databricks, not Databricks itself. If you already know what a lakehouse is and how Delta Lake and Apache Iceberg work, you can skip it, and Prerequisite 2 on Google Cloud may be the better place to start.

This post starts from zero: what a lakehouse is, the open file formats that make one possible, and Apache Spark, the open-source engine that processes the files. Everything here is general knowledge that applies on any cloud and with any vendor. The Databricks platform itself, where each part runs and how you are billed, starts in Part 1: Foundations. Every later part of the series builds on these words.

The running example is the same fictional company as in the Production-ready agents series. Acme Consumer Brands keeps its marketing data in BigQuery and its supply data in Databricks on Google Cloud. In this post we look at the supply side: one table, supply_service, with one row per brand, region and week, showing units ordered and units delivered. This is what four of its rows look like:

SUPPLY_SERVICE, A TABLE IN DATABRICKS brand region week_start units_ordered units_delivered otif_pct Acme Pure Western Europe 2026-04-27 14,338 13,691 95.48 Acme Pure Western Europe 2026-05-04 17,475 15,585 89.18 Acme Pure Western Europe 2026-05-11 16,679 14,799 88.73 Acme Pure Western Europe 2026-05-18 17,448 15,160 86.89 One week before the campaign, then its first three weeks. otif_pct is the share of ordered units that arrived on time and in full.

supply_service: one row per brand, region and week, owned by supply, in Databricks; four of its 390 rows, for Acme Pure in Western Europe

Prerequisite 2 of the Production-ready agents series explains every column.

If you prefer to watch first, here is the heart of this part as a short animated explainer.

Video: what a lakehouse is and how a table format turns files into tables, in three minutes. The narration is a synthetic voice; the script and animation are mine.

What are the key ideas?

Six terms carry this whole post.

  • Data warehouse: a database built for analysis, where data is cleaned and loaded into tables before anyone queries it.
  • Data lake: a large store of raw files, in any format, kept cheaply in object storage (a service that stores files as objects in buckets, such as Cloud Storage). An object is one file’s bytes plus a little metadata, found by its name, and it is replaced whole, never edited in place.
  • Lakehouse: one copy of data kept as open files in object storage, with warehouse features such as transactions and fast SQL added on top.
  • Open table format: a published way of describing a set of files as one table, with a log of every change. Delta Lake and Apache Iceberg are the two you will meet.
  • Catalog: the service that knows which tables exist, gives each one a name and points to its current metadata, so that engines can find a table by name instead of by folder.
  • Compute engine: the software that reads the files and does the work. The best known one for lakehouses is Apache Spark, which spreads one job across many machines.
A LAKEHOUSE, FROM THE BOTTOM UP workloads pipelines SQL, BI AI, ML apps engines (compute) software that reads the files and does the work catalog names each table and points to its current metadata open table format Delta Lake or Apache Iceberg: a change log over files object storage a Cloud Storage bucket full of Parquet files EXAMPLES Apache Spark, Trino, Apache Flink, BigQuery Iceberg REST catalogs, Unity Catalog Delta Lake, Apache Iceberg Parquet files in Cloud Storage Everything from the table format down is open files in your own bucket. That is the lakehouse idea. Everything above it is software that reads and writes those files.

the key ideas as one stack: Parquet files in object storage such as Cloud Storage, an open table format such as Delta Lake or Apache Iceberg that turns the files into tables, a catalog that names the tables and points to their metadata, engines such as Apache Spark that compute on them, and the workloads on top; everything from the table format down is open files in your own bucket

Where do companies keep data for analysis?

A warehouse checks and tidies data before it stores it; a lake stores everything first and leaves the tidying for later. The lakehouse combines the two, so let us start with the two older ideas.

Picture two ways to run a library. In the first, a librarian checks every new book, catalogues it and shelves it in the right place before anyone may borrow it. Finding a book is quick, but nothing gets in until it has been processed, and odd items such as loose maps are awkward. In the second, every donation goes straight into a big storeroom, as it arrives, in whatever box it came in. Anything fits and storing it costs almost nothing, but each visitor has to dig, and two people can reshelve the same box at once. The first library is a data warehouse, the second a data lake, and many companies end up running both, with the same books in each.

A data warehouse is a database designed for questions over years of history. Data is loaded on a schedule, checked against a schema (the fixed list of columns and their types) as it arrives, and stored in the warehouse’s own managed storage. This is called schema on write. Warehouses are excellent at SQL and reporting, and less suited to raw files such as images and logs. BigQuery began as Google Cloud’s data warehouse, and Acme’s marketing team uses it that way. BigQuery now also works on lakehouse tables, as Does Google Cloud have a lakehouse? explains.

A data lake is the opposite design. You land every file as it is, in object storage, and decide how to read it later. This is schema on read. Storage is cheap and any tool can read the files. The problem is that plain files have no transactions: if a job fails halfway through writing, readers can see half a result, and there is nothing to stop two jobs from overwriting each other. On Google Cloud, a lake is usually a set of Cloud Storage buckets (Learning Cloud Part 4 explains them).

How does this apply to Acme? Acme has both libraries. Marketing’s campaign data sits in BigQuery, checked against a schema as it is loaded: the catalogued library. Supply’s delivery files from the distribution centres land in Cloud Storage as they arrive: the storeroom.

Many companies ended up running both, which a 2021 paper by four Databricks authors called the “two-tier data lake + warehouse architecture”. Data is loaded into the lake, then loaded again into the warehouse. The paper’s complaint is that this creates “complexity, delays, and new failure modes”: two copies, two sets of permissions, and reports that disagree.

What is an object?

An object is a sealed parcel: a label, contents the store never opens, and a tag of facts about it, replaced whole but never edited. Think of a left-luggage office. Each parcel has a label you use to collect it, the contents are sealed and the office never looks inside, and a tag records facts such as the weight and when it was handed in. You cannot slip one more item into a sealed parcel; you can only hand in a new parcel under the same label. And the shelf names the staff write on labels, such as “Shelf B”, are just part of the label: the room itself is one big floor.

Mapped to the real terms: the label is the object’s key, the sealed contents are its data, the tag is its metadata, and the shelf names are folder-like prefixes in a flat namespace.

Object storage deserves a closer look, because everything in a lakehouse sits on it. Cloud Storage keeps data in buckets (named containers that belong to a Google Cloud project), and a bucket holds objects. An object has three parts:

  • The key, or object name, such as bronze/deliveries/2026-05-04.json. It is unique within the bucket, and it is how you find the object.
  • The data: the file’s bytes. Cloud Storage does not look inside them. A JSON file and a Parquet file are both just bytes to it.
  • The metadata: name-value pairs about the object, such as Content-Type, the size, the generation number that Cloud Storage assigns, and any custom metadata you add, such as source = dc-rotterdam.

How does this apply to Acme? One delivery file from the Rotterdam distribution centre is one parcel: its label is bronze/deliveries/2026-05-04.json, its contents are the JSON bytes, and its tag carries Content-Type, the size, a generation number and source = dc-rotterdam.

Two details matter for everything that follows. First, the namespace is flat: there are no real folders. bronze/deliveries/ is just the start of the object’s name, and tools such as the Google Cloud console treat the slash as a separator so that it looks like a folder. (A bucket created with hierarchical namespace turned on does have real folders; this post assumes the flat kind.)

Second, objects are immutable. The Cloud Storage documentation says that “an uploaded object cannot change throughout its storage lifetime”. You cannot append a line to an object or change one value inside it. You can only upload a whole new object under the same name, which replaces the old one and gets a new generation number. If Object Versioning is turned on for the bucket, the old object is kept as a noncurrent version.

A BUCKET IS A FLAT LIST OF OBJECTS BUCKET gs://acme-lake-supply bronze/deliveries/2026-04-27.json bronze/deliveries/2026-05-04.json bronze/deliveries/2026-05-11.json supply_service/part-00000-a1b2.parquet supply_service/_delta_log/...0000.json no real folders: "bronze/" is part of the name ONE OBJECT KEY (the name, unique in the bucket) bronze/deliveries/2026-05-04.json DATA (the bytes, opaque to Cloud Storage) {"dc":"RTM","brand":"acme pure ","delivered":"1,204"... 18,442 bytes, uploaded as one unit METADATA (name-value pairs about the object) Content-Type: application/json size: 18442 generation: 1777874400123456 custom: source = dc-rotterdam Objects are immutable. Writing the same key again creates a new object with a new generation number; if Object Versioning is on, the old one is kept as a noncurrent version.

what an object is: a bucket holds objects in one flat list; each object is a key (its name), the data (its bytes) and metadata such as content type, size, generation and custom key/value pairs; folders are only name prefixes, and an object is replaced whole, never edited in place

That immutability is exactly why table formats need a log. If a table is many objects and none of them can be edited, then changing the table means writing new objects and recording, somewhere, which objects make up the table now. That record is the log you will meet in What is an open table format?.

Why are they called schema on write and schema on read?

A schema is the list of columns a table has and the type of each one, for example units_delivered as a whole number. Both designs use a schema. The names tell you when the schema is enforced.

  • Schema on write: the schema is checked when data is written into the table. A row that does not fit is rejected at load time, so everything inside the table already matches the schema.
  • Schema on read: files are written as they are, with no check. The schema is applied when someone reads them. Each reader decides how to interpret the columns, and bad values only surface at query time.

A memory aid that stays accurate: Write: Warehouse checks. Read: Reader decides.

The misconception to drop is that schema on read means “no schema”. Both have one; they differ in who enforces it, and when. An everyday version: a bank counter checks your paying-in slip while you stand there, and hands it back if the amount is unreadable. A night-deposit box accepts any envelope, and the problem is only found the next morning, by whoever opens it. The counter is schema on write; the deposit box is schema on read.

How does this apply to Acme?

Here is the difference on one Acme delivery file. The Rotterdam distribution centre sends a row where units_delivered arrives as the text "1,204", with a thousands separator, instead of the number 1204.

  • In a warehouse, the load checks every value against the column type. A BigQuery load job, for example, accepts no bad records by default: its maxBadRecords setting “is 0, which requires that all records are valid”. The load fails with an error, and no row is written until someone fixes the file.
  • In a lake, the file lands in Cloud Storage unchanged, and nothing complains until a reader queries it with a schema that says units_delivered is an integer. In Apache Spark’s default PERMISSIVE mode, the value it cannot parse becomes null, so the weekly total quietly comes out 1,204 units short. In FAILFAST mode, the query fails instead. A column that is missing from the file altogether also comes back as null.
THE SAME BAD ROW, TWO DESIGNS INCOMING ROW {"brand":"Acme Pure","week_start":"2026-05-04","units_delivered":"1,204"} SCHEMA ON WRITE: CHECKED WHEN DATA IS WRITTEN (WAREHOUSE) SCHEMA ON READ: APPLIED WHEN DATA IS READ (LAKE) delivery file dc-rtm-2026-05-04 .json load: schema check units_delivered INT64 "1,204" is not INT64 load job fails maxBadRecords defaults to 0, so no row is written; fix the file and load it again delivery file dc-rtm-2026-05-04 .json Cloud Storage stored as it is, no check at all later: query with a schema reader says units_delivered INT PERMISSIVE mode: value is null FAILFAST mode: query fails Write: Warehouse checks. Read: Reader decides. The name says WHEN the schema is enforced: on write = before data enters the table; on read = at query time, by each reader.

schema on write versus schema on read, with one bad delivery row where units_delivered arrives as the text 1,204: a warehouse checks the row against the schema while loading and rejects the load; a lake stores the file as it is, and the problem only appears when a reader applies a schema at query time, as a null or an error

The lakehouse brings schema on write back, on top of the same files. In the Databricks documentation’s words, “Databricks validates data quality by enforcing schema on write for Delta Lake tables”: an insert whose columns do not exist in the table, or whose types cannot be safely cast to the table’s types, fails, and the table stays as it was. Changing the schema is still possible, but only on purpose. Schema evolution lets a write add new columns when you ask for it, with INSERT WITH SCHEMA EVOLUTION or the mergeSchema option, and you can always change the schema explicitly with ALTER TABLE ... ADD COLUMN.

What is a lakehouse?

The simple definition: a lakehouse keeps all the data once, as open files in cheap object storage, and adds the reliability and speed of a warehouse on top of those same files.

Back to the two libraries. Instead of copying every book from the storeroom into the catalogued library, you keep one storeroom and give it what the catalogued library had: a catalogue, a loan ledger that records every change, and rules about what may go on each shelf. Visitors from any library can use the same storeroom, because the catalogue and ledger are written in a published format. The storeroom is object storage, the catalogue is the catalog, the ledger and rules are the open table format, and the visitors are the engines.

A common misconception is that a lakehouse is one product you buy. It is a design, and several products implement it: Databricks is one, and Google Cloud’s Lakehouse, below, is another.

How does this apply to Acme? supply_service exists once, as files in Cloud Storage. Databricks reads and writes it, and, once Part 8 is in place, BigQuery reads the same files without a second copy.

The formal definition comes from that paper, Lakehouse: A New Generation of Open Platforms that Unify Data Warehousing and Advanced Analytics (CIDR 2021): a lakehouse is “a data management system based on low-cost and directly-accessible storage that also provides traditional analytical DBMS management and performance features such as ACID transactions, data versioning, auditing, indexing, caching, and query optimization.”

Broken into its parts:

  • Low-cost, directly accessible storage means object storage, where any engine that knows the format can read the files without going through one vendor’s server.
  • ACID transactions (atomic, consistent, isolated, durable) mean a write either fully happens or does not happen at all, and readers never see a half-written table. What does ACID mean? takes each letter in turn.
  • Data versioning and auditing mean you can see who changed a table, and read the table as it was at an earlier version.
  • Indexing, caching and query optimisation are the warehouse techniques for fast SQL, now applied to files. A section below explains each one.

Databricks also documents a common way to organise lakehouse tables, the medallion architecture: raw data lands in bronze tables, is cleaned and deduplicated into silver tables, and is shaped into business-ready gold tables. At Acme, delivery files from the distribution centres land in bronze, and supply_service is a gold table. Is the medallion architecture three copies of the data? answers the question this raises.

Does Google Cloud have a lakehouse?

Yes. Google Cloud’s lakehouse product is called Lakehouse. Until 20 April 2026 it was called BigLake. It keeps tables in the open Apache Iceberg table format, explained later in this post, as files in Cloud Storage. The Lakehouse runtime catalog is the serverless catalog that engines use to find those tables. BigQuery, Managed Service for Apache Spark, Apache Flink, Apache Hive and Trino can all work on the same Iceberg tables, so there is one copy of the data. BigQuery can write to these tables with ordinary SQL (DML and DDL) and looks after their maintenance automatically.

Cross-cloud data access goes a step further. Google first called it cross-cloud Lakehouse, and now uses the name borderless Lakehouse for the product as a whole. Cross-cloud data access has been generally available since 2 November 2026 (it was in Preview before). It lets BigQuery and Spark query Iceberg tables that stay in another cloud or another catalog, for example in Amazon S3 with an AWS Glue catalog, or in Databricks Unity Catalog, without copying the files to Google Cloud first.

So on Google Cloud, BigQuery and Databricks are both lakehouse engines, and both can read the same open files. The rest of this post explains the open ideas underneath, which apply to both.

THREE WAYS TO STORE ANALYTICAL DATA Data warehouse Data lake Lakehouse Stores tables in the engine's own managed storage files of any kind in object storage open table files in object storage Schema on write: checked when data is loaded on read: applied when you query on write, with schema evolution Transactions ACID none on plain files ACID, through the table's log Good at SQL, BI, reports on clean data cheap raw storage, ML on files BI, ML and streaming on one copy of data Weak at unstructured data, ML on raw files reliability, speed, governance needs a catalog and table maintenance On Google Cloud BigQuery Cloud Storage Google Cloud Lakehouse; Databricks on Cloud Storage A lakehouse keeps the lake's cheap, open files and adds the warehouse's guarantees on top.

a data warehouse, a data lake and a lakehouse compared: where data is stored, when the schema is applied, whether there are transactions, what each is good and weak at, and the Google Cloud example of each

What does ACID mean?

ACID means a change either lands completely and safely or not at all, and nobody sees it half done. The classic example is moving £100 from your savings to your current account:

  • Atomic: both halves happen, the £100 leaves savings and arrives in current, or neither does. You never lose £100 in between.
  • Consistent: the bank’s rules hold before and after, for example “a savings balance may not go below zero”.
  • Isolated: a statement printed during the transfer shows the balances before it or after it, never with the money in neither account.
  • Durable: once the app says “transfer complete”, a power cut a second later does not undo it.

A transaction is a group of changes that a system treats as one unit. ACID names the four guarantees a database gives about its transactions. The short version from the definition above still holds: a write either fully happens or does not happen at all, and readers never see a half-written table. Here is each letter, and how Delta Lake provides it on plain files in Cloud Storage. How does this apply to Acme? The same four letters, on supply_service:

  • Atomicity: all of a write’s changes happen, or none of them do. Suppose Acme’s nightly job must write 12 new Parquet files and fails after writing 7. Delta writes the data files first and commits last, and the commit is the single act of creating the next numbered JSON file in _delta_log. The failed job never created that file, so no reader sees any of the 7 files. The Databricks documentation puts it plainly: “Data files are not tracked unless the transaction log records a new version.” Later, VACUUM (a maintenance command) deletes such leftover files, together with old files the table no longer needs, once they are older than the retention period; What does VACUUM delete? explains exactly what goes.
  • Consistency: every commit moves the table from one valid state to another. In database theory this is partly the application’s job, because only the application knows all of its business rules. What Delta enforces is the rules you declare: schema enforcement (columns and types must match) and constraints, such as NOT NULL (a column may not be empty) and CHECK (a condition every row must meet, for example units_delivered >= 0). “When a constraint is violated, the transaction fails with an error,” and the table stays at its previous version.
  • Isolation: jobs running at the same time never see each other’s half-done work. Delta uses optimistic concurrency control: writers take no locks; each one does its work, then checks at commit time whether anyone else has committed a conflicting change in the meantime. If not, it commits. If so, it fails with a conflict error and can be retried. Readers always read a snapshot, one complete version of the table. By default, Databricks provides “snapshot isolation on reads and write-serializable isolation on writes”. The write level is called WriteSerializable: committed writes behave as if they had run one after another.
  • Durability: once a commit succeeds, it stays, even if every cluster crashes a second later. The commit file and the data files are objects in Cloud Storage, so the table has the same durability as the bucket it lives in.
ACID, ONE LETTER AT A TIME What it means How Delta Lake does it Acme example Atomicity all of a write happens, or none of it does the commit is one new log file; data files that no commit names are ignored a 12-file job fails after 7: no commit, so readers see none of the 7 Consistency each commit moves the table from one valid state to another valid state schema enforcement, NOT NULL and CHECK constraints; a violating write fails units_delivered = "1,204" is rejected; the table keeps its last version Isolation concurrent jobs never see each other's half-done work optimistic concurrency; WriteSerializable writes, snapshot isolation reads a dashboard reading during the nightly load sees the version it started with Durability once committed, a change survives crashes and restarts the commit file and the data files live in Cloud Storage version 2 is still there after every cluster shuts down In database theory, part of C is the application's job: Delta enforces only the rules you declare.

the four ACID properties: what each one means, how Delta Lake provides it, and an example from Acme's supply_service table

How does Delta make the commit atomic? Version 2 of the table exists exactly when the object 00000000000000000002.json exists, so committing means creating that one object. The Delta protocol says that “Writers MUST never overwrite an existing log entry”, and that they should use “atomic primitives of the underlying filesystem to ensure concurrent writers do not overwrite each other’s entries”. Cloud Storage offers exactly this kind of primitive: an upload with a generation-match precondition of 0 “only proceeds if no object with the specified name exists”. This pattern is usually called put-if-absent. When two writers race for the same version number, only one can win:

TWO WRITERS RACE TO CREATE VERSION 2 writer A nightly delivery append read version 1 writer B late file append read version 1 _delta_log/ 00000000000000000000.json 00000000000000000001.json 00000000000000000002.json 00000000000000000003.json only one writer can create a version file A: create 02 if absent: OK B: create 02 if absent: already exists, fails B checks for conflicts A only appended: no overlap B: create 03 If B had updated rows that A also changed, the check would find a conflict and B would fail with an error instead, to be retried. Under WriteSerializable, plain appends never conflict.

optimistic concurrency in Delta Lake: two writers both read version 1 and both try to create 00000000000000000002.json; the storage lets only one create it, so writer A wins; writer B's create fails, B reads A's commit, finds no conflict because both only appended, and commits 00000000000000000003.json instead

What if both writers change the same rows?

The first writer to commit wins, and the second is told to start again on the new version; nothing is merged silently. You have seen this on a shared wiki page. Two people open the same page and both edit the same paragraph. The first to press Save succeeds. The second gets an “edit conflict” message: the page changed since they opened it, so they must look at the new version and redo their change on top of it. Nobody locked the page while they typed; the check happens only at save time. That is why it is called optimistic: it assumes conflicts are rare and checks at the end. The wiki page is the table, pressing Save is the commit, and the edit conflict is a conflict exception.

How does this apply to Acme? The race above ended well because both writers only appended. Now suppose writers A and B are two jobs that both run an UPDATE on the same Acme Pure row, and both have permission to do so. Permission is not what decides the outcome; optimistic concurrency is. Both read version 1, and both rewrite the Parquet file that holds the row, part-00002-e5f6.parquet. A commits version 2 first, and that commit removes part-00002. When B tries to commit, Delta checks what has changed since the version B read, finds that a file B read has been removed, and fails B’s write with a conflict exception. The documentation names each one by its cause:

  • ConcurrentDeleteReadException: “a concurrent operation deletes a file that your operation read”, as here.
  • ConcurrentDeleteDeleteException: “a concurrent operation deletes a file that your operation also deletes”, for example when both rewrite the same file.
  • ConcurrentAppendException: “a concurrent operation adds files in the same partition (or anywhere in an unpartitioned table) that your operation reads”.

B commits nothing, so A’s fix is never silently overwritten. B, or the job that runs it, must try again: read version 2, which already contains A’s change, re-run the UPDATE on top of it, and commit version 3.

TWO WRITERS CHANGE THE SAME ROW writer A: UPDATE fix units_delivered for Acme Pure, W. Europe, 05-04 read v1, rewrote part-00002 writer B: UPDATE same row, another job read v1, rewrote part-00002 _delta_log/ 00000000000000000001.json current version when both start 00000000000000000002.json A: remove part-00002, add -00003 00000000000000000003.json B: remove part-00003, add -00004 A: create 02: OK B: create 02: exists conflict: B fails A removed part-00002, which B read: no commit for B B's job retries re-reads v2, re-runs UPDATE B: create 03 on top of A Permission is not the question: both writers may update the table. Optimistic concurrency decides who commits first, and the loser must redo its work on the newer version, so A's fix is never lost.

a real write conflict: writers A and B both read version 1 and both update the same Acme Pure row, so both rewrite part-00002; A creates 00000000000000000002.json first; B's attempt finds that A removed a file B had read, so B fails with ConcurrentDeleteReadException and commits nothing; B's job retries on version 2, re-runs its update on A's new file and commits 00000000000000000003.json, so A's change is never silently overwritten

Whether two writes conflict depends on what each one reads and rewrites. Appends never conflict with each other, while two writes that rewrite the same data file do, as above, even when they change different rows of it. Databricks adds a feature called row-level concurrency that checks for conflicts row by row instead of file by file; Part 7 shows how Databricks implements this.

A failed write is simpler still. Data files that no commit names are not part of the table:

A JOB FAILS HALFWAY: NO COMMIT, SO NOBODY SEES IT write job 12 files planned fails after 7 gs://.../supply_service/ new 1 orphan new 2 orphan new 3 orphan new 4 orphan new 5 orphan new 6 orphan new 7 orphan never never never never never _delta_log/ ...04.json ...05.json ...06.json not created reader reads version 5 only files in the log Data files exist only once a commit names them, so readers never see a half-written table. VACUUM deletes these 7 once they are older than the retention period (7 days by default).

atomicity in Delta Lake: a job meant to write 12 Parquet files fails after writing 7; because it never creates the next commit file, the 7 files are orphans that no reader sees; readers keep using version 5, and VACUUM deletes the untracked files once they are older than the retention period

What do indexing, caching and query optimisation mean?

The lakehouse definition lists three more warehouse features. They serve one goal: answer a query while reading as little data as possible, as fast as possible.

A library makes all three easy to picture. Each shelf has a label such as “Authors A to C”, so you walk past every shelf whose range cannot hold the author you want: that is indexing, in the light form lakehouses use. The books you use every week sit on your desk instead of on a shelf at the far end: that is caching, as long as you notice when a newer edition arrives. And before you set off with a list of ten books, you plan the route so that you visit each aisle once: that is query optimisation. The real terms follow, then the same three ideas on an Acme query.

  • Indexing means keeping structures that let an engine find the rows it needs without reading everything. A classic database keeps a separate index, such as a B-tree, on chosen columns. A lakehouse uses lighter structures that suit files. Delta stores per-file statistics in its log (the minimum and maximum value, the null count and the row count for columns in each data file), and an engine can use them “at query time to skip irrelevant files”. This is called data skipping. It works best when similar values sit in the same files, and two classic layout techniques arrange that. Partitioning splits a table into separate folders by the value of a column (for example one folder per week_start), so a filter on that column reads only the matching folders. ZORDER rewrites files so that rows with similar values in several chosen columns end up in the same files, which makes the min/max ranges narrower and data skipping more effective.
  • Caching means keeping recently read data on fast storage close to the compute, so that the next read is quicker. Reading from object storage over the network is slower than reading a local disk, so an engine can keep a copy of the files it has read on the machine’s own disk or in memory, and must notice when the files change so that it never serves a stale copy.
  • Query optimisation means the engine choosing how to run a query: which files to read, in which order to join tables, and which join strategy to use. Spark SQL’s cost-based optimizer (CBO) uses table and column statistics “to estimate the cost of alternative plans and choose the plan with the lowest estimated cost”. Because estimates can be wrong, adaptive query execution (AQE) re-optimises the plan while the query runs, using the real size of the data produced so far. The engine then executes the plan.
DATA SKIPPING WITH PER-FILE MIN/MAX STATISTICS SELECT * FROM supply_service WHERE week_start = '2026-05-04' STATISTICS IN THE DELTA LOG, ONE ROW PER DATA FILE (ILLUSTRATIVE) data file min week_start max week_start numRecords decision file A 2026-01-05 2026-02-23 98 skip: max too low file B 2026-03-02 2026-04-27 98 skip: max too low file C 2026-05-04 2026-06-22 97 read: range fits file D 2026-06-29 2026-08-17 97 skip: min too high optimiser cost-based optimizer picks the plan from statistics; AQE re-plans while running execution the engine runs the plan on file C only cache can keep a copy of file C on fast local disk for the next query Without statistics the engine must open all 4 files. With them it reads 1 of 4. Clustering keeps similar week_start values in the same files, so the ranges barely overlap.

data skipping: a query for week_start 2026-05-04 checks the minimum and maximum week_start that the Delta log stores for each data file, reads only the one file whose range can contain that date, and skips the other three; the optimiser plans the read, the engine executes it and a cache can keep a local copy for the next query

How does this apply to Acme? A query for week_start = 2026-05-04 on supply_service is the search for one author: the Delta log’s minimum and maximum week_start per file are the shelf labels, so the engine reads one file and skips the other three, as the diagram shows.

Databricks has its own versions of these ideas: a newer layout technique called liquid clustering that replaces partitioning and ZORDER, a disk cache on each worker’s local SSD, and a service that runs maintenance for you. Part 7 shows how Databricks implements each one.

Is the medallion architecture three copies of the data?

No: bronze, silver and gold are three increasingly refined forms of the data, not three identical copies. A restaurant kitchen works the same way. Deliveries go into the store room exactly as they arrived, in their crates, with the delivery note. Then the prep cooks wash, peel and portion them into labelled containers. Finally the line cooks turn those into the plated dishes that go to the tables. Nobody would call the crate of potatoes and the plate of chips “two copies”. If a prep step turns out to be wrong, you go back to the crates.

How does this apply to Acme? The crates are the bronze delivery files, the prepped containers are the silver delivery rows, and the plated dishes are the gold supply_service table. In the documentation’s terms:

They are three separate tables, each a more refined form of the data, produced from the layer before it. So yes, the data is physically stored more than once, but never as three identical copies. The Databricks documentation describes the goal as to “incrementally and progressively improve the structure and quality of data as it flows through each layer”.

  • Bronze keeps the raw data as received. It “is appended incrementally and grows over time”, has minimal validation, and often stores fields as strings so that nothing is lost when a source changes its format. It exists so that you can reprocess and audit: if a silver rule turns out to be wrong, you rebuild silver from bronze.
  • Silver is cleaned, typed, deduplicated and conformed (the same names and codes across sources). The documentation lists schema enforcement, handling of missing values, deduplication and type casting as silver work, and says silver “should always include at least one validated, non-aggregated representation of each record”.
  • Gold is shaped for the business: “often highly aggregated” and modelled for reporting, so it is much smaller. supply_service is a gold table.
THREE TABLES, THREE FORMS OF THE SAME FACTS BRONZE raw, append only supply_bronze.deliveries_raw value: {"dc":"RTM","brand":"acme pure ","region":"W. Europe","wk":"2026-05-04","delivered":"1,204"} _source_file: dc-rtm-2026-05-04.json _ingested_at: 2026-05-04 06:02 value: {"dc":"RTM","brand":"acme pure ","region":"W. Europe","wk":"2026-05-04","delivered":"1,204"} _source_file: dc-rtm-2026-05-04.json _ingested_at: 2026-05-04 06:47 (file re-sent) parse JSON, cast to INT and DATE, fix brand spelling, map "W. Europe" to "Western Europe", drop the duplicate SILVER clean, one row each supply_silver.deliveries dc brand region week_start units_ordered units_delivered RTM Acme Pure Western Europe 2026-05-04 1,250 1,204 ... Acme Pure Western Europe 2026-05-04 ... ... group by brand, region and week_start; sum the units; compute otif_pct (every Acme Pure delivery to Western Europe that week, not just this one) GOLD aggregated supply_service brand region week_start units_ordered units_delivered otif_pct Acme Pure Western Europe 2026-05-04 17,475 15,585 89.18 Each layer is its own Delta table, built from the layer above. Bronze keeps every file as received, silver one row per delivery, gold one row per brand, region and week: far fewer rows.

one Acme delivery through the medallion layers: bronze keeps the raw JSON exactly as received, with the source file name, the ingest time and a duplicate from a re-sent file; silver holds one cleaned, typed and deduplicated row per delivery with a standard region name; gold holds one aggregated supply_service row per brand, region and week

How much is stored more than once depends on retention, which is your choice. Bronze holds every record as received, so it is usually the largest layer. Gold is so much smaller that its extra copy rarely matters. Delta also keeps the files of older table versions, for time travel, until VACUUM removes them (by default once they were removed more than 7 days ago), which adds storage in every layer. My view: decide on purpose how long bronze is kept, long enough to replay and audit, and write that decision down.

What is an open table format?

A table format is a ledger that says which files make up the table right now, and which made it up at every earlier version. Picture a library whose books are never altered once printed. To replace a page, it prints a new edition and records two lines in a numbered loan ledger: “page 41: removed edition 1, added edition 2”. To know what is on the shelf now, you read the ledger from the start, or from the last full stock-take. To know what was on the shelf last Tuesday, you stop reading at Tuesday’s page. The printed books are the Parquet data files, the ledger is the table format’s log, the stock-take is a checkpoint, and stopping early is time travel.

A lakehouse table is still a folder of files. The data itself is usually stored as Apache Parquet (a file format that stores data by column and compresses it well). What turns a folder of Parquet files into a table with transactions is a table format: a set of metadata files that records which data files belong to the table at each version.

Delta Lake is the format Databricks created and later open-sourced. Its documentation describes it as Parquet data files plus “a file-based transaction log for ACID transactions and scalable metadata handling”. The log, defined in the Delta transaction log protocol, is a folder called _delta_log next to the data. Every change to the table is a new numbered JSON file (00000000000000000000.json, 00000000000000000001.json and so on) listing the files added and removed. A reader that replays the log knows exactly which Parquet files make up the current table, and can also read any earlier version, which Delta calls time travel. Delta is the default table format on Databricks. For Acme, the ledger is the _delta_log folder next to the supply_service files; the sections below open it.

Apache Iceberg is another open table format with the same goal, defined by the Apache Iceberg table specification. It keeps its own metadata and snapshots (versions of the table), and an Iceberg catalog

points at the current metadata. Many engines can read and write Iceberg tables, BigQuery among them. On Google Cloud, Lakehouse manages Iceberg tables for BigQuery and the open-source engines.

Because both formats store data as Parquet and differ mainly in their metadata, Delta Lake offers UniForm (Universal Format). With UniForm turned on, the Delta writer also writes Iceberg metadata alongside the Delta log, so “a single copy of the data files supports both Delta and Iceberg clients”. At Acme, this is how marketing’s BigQuery queries will read supply’s Delta table without a copy; Part 8 shows how.

ONE TABLE, ONE COPY OF THE DATA, TWO METADATA LAYERS gs://acme-lake-supply/supply_service/ _delta_log/ 00000000000000000000.json 00000000000000000001.json 00000000000000000002.json ... part-00000-a1b2.parquet part-00001-c3d4.parquet part-00002-e5f6.parquet Iceberg metadata files (from UniForm) Delta readers Databricks, Spark, other Delta clients read the _delta_log commits The data itself Parquet files: columns of rows, compressed; shared by both Iceberg readers BigQuery and other Iceberg clients read the Iceberg metadata Each JSON file is one commit: which Parquet files were added or removed. Readers see whole commits or nothing, which is what makes writes ACID. Paths and names here are simplified.

anatomy of a Delta table with UniForm turned on: Parquet data files, a _delta_log folder of numbered JSON commits that Delta readers such as Databricks use, and Iceberg metadata generated from the same commits for Iceberg readers such as BigQuery; both read one copy of the data files

How does it work? There is one copy of the Parquet files and two metadata layers. In the documentation’s words, “UniForm automatically generates Iceberg metadata asynchronously alongside the Delta Lake metadata, without rewriting the Parquet data files”. Turning it on is a matter of a few table properties, and it comes with conditions that follow from how Iceberg readers work.

The best known condition concerns deletion vectors. A deletion vector marks rows of a Parquet file as deleted in a small separate file, so a DELETE or UPDATE does not rewrite the whole Parquet file; readers apply the marks at query time. This approach is called merge-on-read, as opposed to copy-on-write, where the file is rewritten. An Iceberg v2 reader cannot apply Delta’s deletion vectors, so a table that Iceberg v2 readers must read through UniForm does without them, and every DELETE and UPDATE rewrites whole files. Iceberg v3 supports deletion vectors, which removes that trade-off for readers that support version 3 of the specification. Part 8 shows the exact table properties, the limits and how Databricks and BigQuery use them.

What does a Parquet file look like inside?

Parquet is a binary format: open a Parquet file in a text editor and you see unreadable bytes. It is designed to be read by programs. The Apache Parquet documentation describes the layout:

  • The file starts and ends with a 4-byte magic number, PAR1, which marks it as Parquet.
  • The rows are split into row groups. Inside each row group, the values of each column are stored together in a column chunk.
  • Each column chunk is split into pages, the unit that is encoded (for example with a dictionary, so a repeated value is stored once and then referred to by a small number) and then compressed (for example with Snappy or Zstandard).
  • At the end comes the footer, the file metadata: the schema, the number of rows, where each column chunk starts and, for each column chunk, statistics such as the minimum and maximum value. Then 4 bytes give the footer’s length, and PAR1 closes the file.

“File metadata is written after the data to allow for single pass writing.” A reader does the opposite: it reads the end of the file first, finds the footer, and fetches only the column chunks it needs.

INSIDE PART-00000-A1B2.PARQUET (BINARY: THIS IS ITS LAYOUT) PAR1 4-byte magic number ROW GROUP 0 (rows 1 to 98) column chunk: brand column chunk: region column chunk: week_start column chunk: units_ordered column chunk: units_delivered column chunk: otif_pct ROW GROUP 1 (rows 99 to 195) the same six column chunks FOOTER: FILE METADATA schema: 6 columns and types num_rows: 195, row groups: 2 per column chunk: offset, encoding, min, max, null_count footer length (4 bytes) PAR1 4-byte magic number Column chunk all values of one column for one row group, stored together Pages inside a chunk: encoded (for example dictionary or run-length), then compressed (Snappy or Zstandard) Footer written last, read first: the schema, where each chunk starts, and min/max statistics a reader uses to skip Reading read the last 8 bytes, jump to the footer, fetch only needed chunks

the layout of a Parquet file, which is binary and not human readable: the magic bytes PAR1 at the start and end, row groups that each hold one column chunk per column, column chunks split into encoded and compressed pages, and a footer with the schema, row counts and min and max statistics for every column chunk, followed by the footer length

If Parquet were readable, the difference from a row format would look like this, using the four supply_service rows from the top of the post:

THE SAME FOUR ROWS, BY ROW AND BY COLUMN (AS IF READABLE) ROW LAYOUT (LIKE CSV OR JSON) Acme Pure,Western Europe,2026-04-27,14338,13691,95.48 Acme Pure,Western Europe,2026-05-04,17475,15585,89.18 Acme Pure,Western Europe,2026-05-11,16679,14799,88.73 Acme Pure,Western Europe,2026-05-18,17448,15160,86.89 COLUMN LAYOUT (LIKE PARQUET) brand Acme Pure x4 (stored once, then counted) region Western Europe x4 week_start 2026-04-27 05-04 05-11 05-18 units_ordered 14338 17475 16679 17448 units_delivered 13691 15585 14799 15160 otif_pct 95.48 89.18 88.73 86.89 SELECT AVG(otif_pct) FROM supply_service by row: read all 24 values. by column: read 4. WHY COLUMNS HELP ANALYTICS 1. A query reads only the columns it names, not whole rows. 2. Similar values sit together, so encoding and compression work far better. 3. Each row group's footer statistics let a reader skip it without opening it. Real Parquet stores these as encoded binary pages; you need a tool to see the values.

the four supply_service rows as they would read if Parquet were text: a row layout keeps each row together, a column layout keeps each column together; a query for the average otif_pct reads every value in the row layout but only one column in the column layout, and repeated values such as Acme Pure compress to almost nothing

What does a Delta log look like?

The Delta log is plain text that you can open and read. The protocol says that commit files “use newline-delimited JSON format, where every action is stored as a single-line JSON document”. An action is one change to the table’s state, such as adding a file. This is the first commit of supply_service, with each action wrapped onto several lines so that you can read it, and long values shortened:

_DELTA_LOG/00000000000000000000.JSON (PLAIN TEXT) # line 1 commitInfo: who did what, and when (free-form provenance) {"commitInfo":{"timestamp":1777874400000,"userName":"supply-pipeline@acme.example","operation":"CREATE TABLE AS SELECT", "operationParameters":{"partitionBy":"[]"}}} # line 2 protocol: the reader and writer versions a client must support {"protocol":{"minReaderVersion":1,"minWriterVersion":2}} # line 3 metaData: table id, file format, schema, partitioning, settings {"metaData":{"id":"5f1c2d9e-...","format":{"provider":"parquet","options":{}},"partitionColumns":[], "configuration":{},"createdTime":1777874400000, "schemaString":"{\"type\":\"struct\",\"fields\":[{\"name\":\"brand\",\"type\":\"string\",\"nullable\":true, \"metadata\":{}},{\"name\":\"region\",...},{\"name\":\"week_start\",\"type\":\"date\",...},...]}"}} # line 4 add: one data file, with per-file statistics stored as a JSON string {"add":{"path":"part-00000-a1b2.parquet","partitionValues":{},"size":48211,"modificationTime":1777874400000, "dataChange":true,"stats":"{\"numRecords\":195,\"minValues\":{\"week_start\":\"2026-01-05\",...}, \"maxValues\":{\"week_start\":\"2026-03-30\",...},\"nullCount\":{\"week_start\":0,...}}"}} # line 5 add: the second data file (195 more rows, week_start 2026-04-06 to 2026-06-29) {"add":{"path":"part-00001-c3d4.parquet","partitionValues":{},"size":48302,...,"stats":"{\"numRecords\":195,...}"}} Each action is one line in the real file; wrapped and shortened here. Field names are as in PROTOCOL.md. Shown without UniForm; turning UniForm on raises the protocol versions and adds table features.

the first commit of the supply_service Delta table, _delta_log/00000000000000000000.json: a plain-text file with one JSON action per line, commitInfo, protocol, metaData with the schema, and one add action per Parquet data file with its statistics; long values are shortened and each action is wrapped here for reading

Line by line:

  • commitInfo records who ran which operation, and when. The protocol lets engines store any JSON here.
  • protocol says which version of the Delta protocol a reader (minReaderVersion) and a writer (minWriterVersion) must support to use the table safely.
  • metaData gives the table a unique id, says the data files are Parquet ("provider":"parquet"), holds the schema as a JSON string in schemaString, and lists the partitionColumns (none here) and table settings (configuration).
  • Each add names one data file, with its path, size and modificationTime, and carries stats: the numRecords, minValues, maxValues and nullCount that data skipping uses.

The second commit shows why the log matters on immutable storage. Someone corrected one units_delivered value. Parquet files cannot be edited, so Delta wrote a new file containing the fix and recorded the swap as a remove of the old file and an add of the new one, in one atomic commit. (A table with deletion vectors can mark changed rows instead of rewriting the file; Acme’s table has UniForm turned on for Iceberg v2 readers, so it does without them.) The old file stays in the bucket, which is what makes time travel back to version 0 possible, until VACUUM deletes it after the retention period (see What does VACUUM delete? below).

Replaying thousands of small JSON files would be slow, so writers also write checkpoints from time to time: a Parquet file such as 00000000000000000010.checkpoint.parquet that holds the whole table state at that version. A small file called _last_checkpoint points to the latest one, so a reader starts there and replays only the commits after it.

_DELTA_LOG/00000000000000000001.JSON: AN UPDATE {"commitInfo":{"timestamp":1777960800000,"operation":"UPDATE","operationParameters":{"predicate":"..."}}} {"remove":{"path":"part-00001-c3d4.parquet","deletionTimestamp":1777960800000,"dataChange":true}} {"add":{"path":"part-00002-e5f6.parquet","partitionValues":{},"size":48290,"modificationTime":1777960800000, "dataChange":true,"stats":"{\"numRecords\":195,...}"}} Parquet files cannot be edited, so fixing one value rewrites the whole file: old file removed, new file added. LATER: A CHECKPOINT, SO READERS NEED NOT REPLAY EVERY COMMIT 00000000000000000000.json 00000000000000000001.json ... 00000000000000000010.json 00000000000000000010.checkpoint.parquet 00000000000000000011.json 00000000000000000012.json _last_checkpoint {"version":10,"size":24} 1 read _last_checkpoint it says: version 10 2 read the checkpoint the whole table state at version 10, in Parquet 3 replay 11 and 12 apply their add and remove actions: now at version 12 Checkpoints and _last_checkpoint are defined in PROTOCOL.md. How often a checkpoint is written is the writer's choice. The checkpoint is Parquet, so it is binary; the commits around it stay plain JSON.

the second commit, 00000000000000000001.json, records an UPDATE as a remove of the old Parquet file and an add of its rewritten replacement; further down, every so often a checkpoint file stores the whole table state in Parquet, and _last_checkpoint points to it, so a reader loads the checkpoint and replays only the commits after it

What does VACUUM delete?

Removed files do not disappear by themselves. A remove action only takes a file out of the current version; the file stays in the bucket so that older versions can still be read. VACUUM is the command that finally deletes files. The documentation describes it as removing “data files no longer referenced by a table that are older than the retention threshold”. In practice that covers two kinds of file:

  • files that earlier commits removed, such as part-00001-c3d4.parquet after the UPDATE above, once they were removed longer ago than the retention period;
  • files that no commit ever referenced, such as the leftovers of a failed write, once they are older than the retention period.

The retention period is the table property delta.deletedFileRetentionDuration, which is “interval 7 days” by default. After VACUUM, “the ability to query table versions older than the retention period is lost”, because their files are gone. VACUUM does not delete the log: “log files are deleted automatically and asynchronously after checkpoint operations and are not governed by VACUUM”, and how long they are kept is set by delta.logRetentionDuration, 30 days by default. Databricks can also run VACUUM for you; Part 7 shows how.

VACUUM ON 21 MAY, WITH THE DEFAULT 7-DAY RETENTION v0 05-04 v1 05-05 v2 05-06 v3 05-07 v4 05-14 v5 05-20 VACUUM 05-21 7 days before VACUUM: retention line part-00000-a1b2 kept: still in the table part-00001-c3d4 deleted part-00002-e5f6 deleted part-00003-g7h8 deleted part-00004-i9j0 kept: still in the table part-00005-k1l2 kept: removed recently part-00006-m3n4 kept: still in the table failed-job files deleted: uncommitted _delta_log/ (JSON commits, checkpoints): not touched by VACUUM. Log entries expire after delta.logRetentionDuration (30 days by default), cleaned up automatically after checkpoints. After this VACUUM, time travel to v0 to v3 fails: their removed files are gone. v4 and v5 still work. The 7 days is delta.deletedFileRetentionDuration; raise it if you need a longer time-travel window.

what VACUUM deletes, run on 21 May with the default 7-day retention: each bar is one data file, from the version that added it to the version that removed it; files removed before the retention line, and the untracked files of a failed job, are deleted; files still in the table or removed within the last 7 days are kept; the _delta_log files are not touched by VACUUM and expire separately after 30 days

What does Iceberg metadata look like?

Iceberg arranges its metadata as a tree. Only the top of the tree is text; the levels below it are binary.

  • The catalog stores, for each table, a pointer to its current metadata file. Committing a change means atomically swapping that pointer to a new metadata file.
  • The metadata file is plain JSON. The specification’s example name is v3.metadata.json; UniForm names its files <version-number>-<uuid>.metadata.json in the table’s metadata/ folder. It holds the format-version, a table-uuid, the table’s location, its schemas and partition-specs, the list of snapshots and the current-snapshot-id.
  • Each snapshot is one version of the table, and it names one manifest list.
  • The manifest list lists the manifest files for that snapshot, with counts of added, existing and deleted files. Each manifest lists data files, with each file’s path, format, row count and per-column lower and upper bounds, which play the same role as Delta’s minimum and maximum statistics. Manifest lists and manifests are stored in Apache Avro, a compact binary format.
  • The data files are Parquet, as before.
ICEBERG METADATA, TOP TO BOTTOM catalog readable supply.supply_service points to current file metadata_location: gs://acme-lake-supply/supply_service/metadata/v3.metadata.json v3.metadata.json JSON, readable schemas, partition specs, snapshots {"format-version":2,"table-uuid":"9d3e41c0-...","location":"gs://acme-lake-supply/...", "current-schema-id":0,"schemas":[{"schema-id":0,"type":"struct","fields":[ {"id":1,"name":"brand","required":false,"type":"string"},...,{"id":3,...}]}], "default-spec-id":0,"partition-specs":[{"spec-id":0,"fields":[]}], "current-snapshot-id":7741,"snapshots":[{"snapshot-id":7741,"timestamp-ms":1777960800000, "summary":{"operation":"overwrite"},"manifest-list":".../metadata/snap-7741-1-....avro"}]} manifest list Avro, binary one entry per manifest file # as if readable: one record per manifest {"manifest_path":".../metadata/7741-m0.avro","manifest_length":6912,"partition_spec_id":0, "added_snapshot_id":7741,"added_files_count":1,"existing_files_count":1, "deleted_files_count":1} manifest file Avro, binary one entry per data file, with stats # as if readable: one record per data file (status 1 = ADDED) {"status":1,"snapshot_id":7741,"data_file":{"content":0, "file_path":".../part-00002-e5f6.parquet", "file_format":"PARQUET","partition":{},"record_count":195,"file_size_in_bytes":48290, "lower_bounds":{3:"2026-04-06"},"upper_bounds":{3:"2026-06-29"}}} # bounds are keyed by column id (3 = week_start) and stored as bytes data files Parquet, binary the rows themselves part-00000-a1b2.parquet part-00002-e5f6.parquet # the same files the Delta log lists, when UniForm writes this metadata Only the top two levels are text. The catalog swap to a new metadata file is Iceberg's commit, the way the next numbered JSON file is Delta's. Versions 1 to 3 of the spec use this tree.

how an Iceberg reader finds the data for supply_service: the catalog points to the current metadata file, a readable JSON file listing schemas, partition specs and snapshots; the current snapshot names a manifest list, which lists manifest files, which list the Parquet data files with their statistics; manifest lists and manifests are Avro and data files are Parquet, both binary, shown here as if readable

This tree is the one used by versions 1 to 3 of the specification. With UniForm turned on, the Delta writer generates this Iceberg tree from the Delta log, and both point at the same Parquet files, as in the anatomy diagram earlier in this section.

How do Delta Lake and Iceberg compare?

Both formats do the same jobs on the same kind of files, in different ways. Side by side, using the Delta protocol and the Iceberg specification:

SAME JOB, TWO DESIGNS Job Delta Lake Apache Iceberg Find the current table state read _last_checkpoint, the checkpoint, then newer JSON commits up to the highest ask the catalog for the current metadata.json Schema, versions metaData actions in the log; each commit file is a version schemas and snapshots lists inside metadata.json Per-file stats for skipping stats in each add action (and in checkpoints) lower_bounds, upper_bounds in manifests, keyed by column ID; partition summaries in the manifest list Make a commit atomic create the next numbered JSON file only if absent swap the catalog's pointer to a new metadata.json Metadata file formats JSON commits, Parquet checkpoints JSON metadata, Avro manifest lists and manifests Row-level deletes deletion vectors (optional; off under IcebergCompatV2) v2: position or equality delete files; v3: deletion vectors Data files Parquet Parquet (the spec also allows ORC and Avro) Same idea underneath: immutable Parquet files plus metadata that says which files make up each version.

Delta Lake and Apache Iceberg compared job by job: how a reader finds the current state, where schemas and versions live, where per-file statistics are kept, how a commit is made atomic, which file formats the metadata uses, how row-level deletes work, and the data files, which are Parquet in both

Which should I use? The misconception is that you must pick one format for the whole company. You pick per table, by who writes it and who must read it. My rule of thumb, in the order I would check:

Question                                              Then
1. Is the table written and read only by Databricks?  Delta, the default. Stop here.
2. Must Iceberg-only readers, such as BigQuery,       Delta with UniForm: one copy,
   read a table that Databricks writes?               Iceberg metadata alongside.
3. Is the table written mainly by other engines,      Iceberg, managed where it is written.
   such as BigQuery or open-source Spark?
4. Only then: do readers need features that one       Check the limits in Part 8
   format's metadata cannot express?                  before choosing.

How does this apply to Acme? supply_service is written by Databricks and read by marketing’s BigQuery queries, so it sits at question 2: a Delta table with UniForm turned on, which is the design described above.

What is Apache Spark?

The Apache Spark project describes itself as “a multi-language engine for executing data engineering, data science, and machine learning on single-node machines or clusters”. In plain words, Spark splits one large job into many small tasks and runs them in parallel on a group of machines called a cluster.

An election count is a good picture. One returning officer plans the count and hands each counting table its own ballot boxes. Every table counts its boxes at the same time, and the officer adds the tables’ totals into one result. When the count is over, the counters go home, but the ballot boxes stay in the store, ready for a recount with more or fewer tables. The officer is the driver, the tables are executors on worker machines, the boxes are the data files, and the store is object storage.

A Spark cluster has two kinds of process. The driver runs your program: your notebook or job code. It plans the work and splits it into tasks. The executors run on the worker machines and carry out those tasks, each on its own slice of the data.

The key design choice is that storage and compute are separate: the data stays in object storage, so when the cluster shuts down the data is still there, and tomorrow you can start a bigger or smaller cluster.

How does this apply to Acme? When a job adds up units_delivered across Acme’s delivery files, the driver splits the files into tasks, each executor reads and sums its own slice of the Parquet files in Cloud Storage, and the driver combines the partial sums. When the cluster stops, the files and the supply_service table are still in the bucket.

Databricks builds on Spark with its own runtime and its own query engine, Photon; Part 7 explains both.

ONE DRIVER, MANY EXECUTORS, DATA IN OBJECT STORAGE Driver runs your notebook or job code; plans the query; makes tasks Executor on worker 1 tasks on file slice 1 Executor on worker 2 tasks on file slice 2 Executor on worker 3 tasks on file slice 3 Executor on worker 4 tasks on file slice 4 Cloud Storage part-00000.parquet part-00001.parquet part-00002.parquet part-00003.parquet Storage and compute are separate: the files stay in the bucket when the cluster shuts down. Add workers and each one reads a smaller slice, which is how Spark scales out. blue dots: tasks out dark dots: data read orange dots: partial results back

how Apache Spark runs a query: the driver turns your code into tasks and sends them to executors on worker nodes; each executor reads its own slice of the Parquet files from Cloud Storage in parallel, and the partial results come back to the driver

What can go wrong?

  • Treating a folder of Parquet files as a table. Without a table format there are no transactions: a job that fails halfway leaves half a result that readers can see, and two jobs can overwrite each other. Put a log on top, Delta Lake or Iceberg, before anyone relies on the data.
  • Expecting time travel beyond the retention period. After VACUUM, table versions older than delta.deletedFileRetentionDuration (7 days by default) can no longer be read. Raise it before you need an older version, and remember that it also keeps more files in storage.
  • Assuming concurrent updates simply merge. Two writers that rewrite the same file conflict, and the loser commits nothing. Jobs that update the same table at the same time need a retry, or a schedule that keeps them apart.
  • Finding bad data at query time. With schema on read, a value such as "1,204" quietly becomes null in Spark’s default PERMISSIVE mode. Enforce the schema and add constraints where the data enters the table, not in every report.
  • Keeping bronze forever by accident. Bronze is usually the largest layer, and old table versions add more until VACUUM runs. Decide how long each layer is kept, and write it down.

What would I do now?

  1. Read the lakehouse paper once. It is short, and it explains why the two-tier lake plus warehouse design caused so much trouble.
  2. Open the _delta_log folder of one real Delta table and read the first commit, line by line, against the Delta protocol.
  3. Sketch your own medallion layers for one data source: what bronze keeps, what silver cleans and what gold aggregates, and how long each is retained.
  4. List the engines that must read your most important table. If any of them speaks only Iceberg, note it now; Part 8 shows how UniForm serves both from one copy.
  5. Go on to Prerequisite 2 for the Google Cloud side and Prerequisite 3 for identity and security, or straight to Part 1 if you already know both.

What did we learn?

  • A data lake stores files as objects in object storage: each object is a key, its bytes and some metadata, in a flat namespace, and it is replaced whole rather than edited, which is why table formats need a log.
  • Schema on write checks data as it enters a table and rejects bad rows at load time; schema on read stores files as they are and leaves each reader to interpret them at query time, so problems surface late.
  • A lakehouse keeps one copy of the data as open files and adds warehouse features on top: ACID transactions, schema enforcement, and fast SQL through data skipping, file layout, caching and query optimisation.
  • Delta Lake commits by creating the next numbered JSON file in _delta_log, which only one writer can do; a writer that loses a real conflict commits nothing and must redo its work on the newer version, and VACUUM later deletes files that no version inside the retention period (7 days by default) still needs.
  • Parquet data files are binary and columnar, the Delta log is plain JSON with Parquet checkpoints, and Iceberg metadata is a JSON file above Avro manifest lists and manifests; UniForm lets one copy of the Parquet files serve both, and the medallion layers are separate, more refined tables rather than identical copies.
  • Apache Spark splits one job into tasks that a driver hands to executors on a cluster, and because storage and compute are separate, the data outlives every cluster.

What comes next?

Prerequisite 2: What a cloud is, and what Google Cloud is covers the other half: Google Cloud’s regions, projects, billing accounts, IAM and the services this series uses, with a vocabulary map from AWS and Azure, and Prerequisite 3: Identity and security foundations covers single sign-on, OAuth, SCIM, keys and encryption. If you already know Google Cloud and those identity ideas well, go straight to Part 1: Foundations, how Databricks is built on Google Cloud, which starts with what Databricks is and builds on every idea in this post.

Sources

Opinions are my own. Every fact in this post comes from the public documentation linked above. Acme Consumer Brands and its data are fictional.