The parts of a table format, defined one at a time: the transaction log and the metadata tree, optimistic concurrency, copy-on-write and merge-on-read, compaction, data skipping, clustering, time travel, vacuum, deletes, change feeds, Hudi's table and query types, indexes and catalogs.
What a format records about a table, how a commit becomes visible, and how two writers are kept from stepping on each other.
How writes land
4
Copy-on-write, merge-on-read, compaction and deletes: the four ways a change reaches immutable files.
How reads stay fast
4
Statistics, clustering, file sizes and partition specs: what lets a query skip most of a table.
History, change & cleanup
4
Time travel, vacuum, snapshot expiry, change feeds and schema evolution: how a table remembers and how it forgets.
Hudi specifics & catalogs
4
Hudi's table and query types and its index, and the catalog every format needs to be found.
Evergreen · asked verbatim
10
The flat form, in the words interviewers use. Definitions that come up as one-line questions.
01 / 30
Reported · 2Why table formats exist
What is a table format? What does it add on top of a folder of Parquet files?
Why they ask this
It is the first question on the subject, and the answer separates people who have read the marketing from people who know what is in the metadata directory.
Say this
A table format is a specification for tracking which data files make up a table at each point in time, kept in a metadata layer next to the files. On top of plain Parquet it adds atomic commits, snapshots for time travel, schema and partition metadata, and statistics for pruning, which is what makes updates and concurrent writers safe.
The reasoning
A folder of Parquet files has no notion of a table. A reader lists the directory and takes whatever is there. If a writer is halfway through, the reader sees half. If two jobs write at once, their files interleave. There is no schema except what the files happen to contain.
Delta Lake, Apache Iceberg and Apache Hudi each keep a metadata layer that says exactly which files are the table at version N. A commit adds a new version atomically. A reader picks one version and sees only its files, so a write in progress is invisible.
That metadata also carries the table's schema, its partition spec, and per-file statistics such as min and max per column. The engine prunes files from the metadata without listing storage, and evolves schema or partitioning by writing metadata, not by rewriting data.
The three formats differ in the shape of the metadata: Delta's ordered JSON log, Iceberg's tree of metadata files and manifests, Hudi's timeline. The guarantees are the same family.
The answer most people give
"A table format is a file format like Parquet." Parquet is the file. The table format is the layer above that says which Parquet files are the table right now.
They’ll ask next
Two Spark jobs write to the same Parquet folder at the same moment with no table format. What does a reader see?
What is the Delta Lake transaction log? What does one commit write, and what is a checkpoint file?
Why they ask this
Delta is the most common format in interviews, and the `_delta_log` folder is the whole mechanism in one place.
Say this
The transaction log is the `_delta_log` directory: an ordered sequence of JSON files, one per commit, each listing the files added and removed plus metadata changes. Delta's docs call it the source of truth. Periodically Delta writes a Parquet checkpoint that summarises the log so a reader does not replay every JSON file.
The reasoning
Every write to a Delta table produces a new numbered JSON file: `000000000000000042.json`. It contains actions. `add` records each new data file with its statistics. `remove` records each file that is no longer part of the table. `metaData` and `protocol` record a schema or format-version change.
A reader reconstructs the table by replaying the actions in order: the set of files added and not removed is the table at that version. Because a commit is one file written atomically, a reader sees either the whole commit or none of it.
Replaying thousands of JSON files would be slow, so Delta writes a checkpoint: a Parquet file holding the full state at that version, every 10 commits by default. A reader loads the latest checkpoint and only the JSON files after it.
The log is also the history. `delta.logRetentionDuration`, documented at 30 days by default, decides how far back time travel can reach. The Delta docs say that each time a checkpoint is written, log entries older than the retention are cleaned up.
The answer most people give
"The log stores the data changes, like a database redo log." It stores which files were added and removed. The rows themselves live only in the Parquet files.
They’ll ask next
A commit's JSON file is written but the writer crashes before updating anything else. Is the commit visible?
What is Iceberg's metadata tree? What are the metadata file, the manifest list and a manifest?
Why they ask this
It is the reason Iceberg plans a query without listing directories, and candidates who can draw the three levels understand the format.
Say this
An Iceberg table is a tree. The catalog points at one metadata file, which lists snapshots. Each snapshot points at one manifest list. The manifest list names the manifests, with partition ranges. Each manifest lists data files and delete files, with per-file statistics. A reader walks the tree and prunes at every level.
The reasoning
At the top, the catalog holds a pointer to the current `metadata.json`. That file holds the schema, the partition spec, the sort order, table properties, and the list of snapshots with their timestamps and parent ids.
A snapshot is the table at one commit. It points at a manifest list, an Avro file with one row per manifest, including the partition value ranges each manifest covers. A query filtering on `dt` can skip whole manifests from this level alone.
A manifest is an Avro file listing data files, and in format v2 also delete files, each with its partition, record count and column-level lower and upper bounds. The planner skips files whose bounds exclude the query's predicate.
A commit writes new manifests and a new manifest list, then a new metadata file, and finally swaps the catalog pointer. The Iceberg configuration page documents `write.metadata.previous-versions-max` at 100 and `commit.retry.num-retries` at 4.
The answer most people give
"Iceberg lists the files in each partition folder." That is Hive. Iceberg never lists directories; every file it reads is named in a manifest.
They’ll ask next
A table has 5,000 data files. A commit appends 10. How many manifests does the commit write, and does it rewrite the old ones?
What is optimistic concurrency control in a table format? What happens when two writers commit at once?
Why they ask this
It is how a lake gets ACID without a lock server, and the failure mode, a retry or a conflict error, is what people meet in production.
Say this
Each writer reads the current version, does its work assuming nothing changes, and at commit time tries to publish the next version atomically. If another writer got there first, the commit is rejected; the writer re-checks whether the other change conflicts with its own, and either retries on top of it or fails.
The reasoning
Optimistic means nobody takes a lock while working. A writer starts from version 41, writes its new data files, and prepares version 42. The only critical section is the publish: a compare-and-swap of the table pointer from 41 to 42.
If a second writer already published 42, the swap fails. The writer then reads the new state and checks for a real conflict. Two appends to different partitions do not conflict, so it retries and publishes 43. Two updates to the same files do conflict, and one of them fails with a concurrent modification error.
Delta does this through the log directory, where writing commit file 42 must be atomic, which object stores need help with. Iceberg does it through the catalog's atomic pointer swap, with `commit.retry.num-retries`, documented at 4. Hudi's default is single writer; `OPTIMISTIC_CONCURRENCY_CONTROL` adds a lock provider for multi-writer.
Isolation is snapshot isolation for readers: a reader on version 41 keeps reading 41's files even after 42 lands, because the files are immutable and the metadata is versioned.
The answer most people give
"The format locks the table while a job writes." Nothing is locked during the write. Only the publish is serialised, which is why long jobs can fail at the very end.
They’ll ask next
A 40-minute MERGE fails at commit because a 15-minute sync wrote first. What decides whether the MERGE retries or dies?
What is copy-on-write in a table format? What does it cost the writer, and what does it give the reader?
Why they ask this
It is one half of the most reported table-format question, and the answer has to be about files, not slogans.
Say this
Copy-on-write means an update or delete rewrites every data file that holds an affected row, producing a new file with the change applied, and the commit swaps the new file in for the old. Writes are expensive because a 1-row change rewrites a whole file. Reads are cheap because every file is final and nothing is merged at query time.
The reasoning
Parquet files are immutable, so a row cannot be changed in place. Under copy-on-write the engine finds the file that holds the row and reads the whole file. It writes a new file with the row changed. Then it records in the metadata that the old file is removed and the new one added.
The cost is proportional to the files touched, not the rows changed. Updating 1 row in a 120 MB file rewrites 120 MB. Updating 1,000 rows spread across 1,000 files rewrites 1,000 files. That is why copy-on-write suits batch updates that are rare or concentrated.
The reader's view is clean. Every file is the current truth, so a scan reads Parquet and nothing else, with full pushdown and statistics. BI and reporting tables, where reads dominate, favour copy-on-write.
It is the default in all three formats: Delta without deletion vectors, Iceberg's `write.update.mode` and `write.delete.mode` documented as copy-on-write, and Hudi's `hoodie.table.type` documented as `COPY_ON_WRITE`.
The answer most people give
"Copy-on-write copies the table on every write." It copies the files that hold affected rows, not the table. A partition-aligned update touches only that partition's files.
They’ll ask next
A table receives 2 million small updates a day spread over all dates. Why does copy-on-write struggle, and what replaces it?
Reported · 2Copy-on-write vs merge-on-readUpserts, CDC & deletes
What is merge-on-read? How does each format store the changes, and when does the merge happen?
Why they ask this
The other half of the reported question, and the half with three different implementations behind one name.
Say this
Merge-on-read writes a change as a small delta next to the base file instead of rewriting the base, and the reader merges base plus deltas at query time. Hudi writes row-based log files per file group; Iceberg v2 writes position or equality delete files; Delta writes deletion vectors. Compaction later folds the deltas back in.
The reasoning
The write path is cheap: append a small file describing what changed and commit. A million small updates a day become a million small appends, not a million file rewrites. That is why merge-on-read suits streaming and CDC.
In Hudi, a merge-on-read table keeps a base Parquet file per file group and a stack of Avro log files holding updates and deletes for that group. In Iceberg format v2, a delete file marks rows to skip: a position delete names file and row number, an equality delete names column values. In Delta, a deletion vector is a bitmap of rows in a file that are logically deleted.
The read path pays. A snapshot query must read the base file, then apply the logs, delete files or vectors before returning rows. The more deltas accumulate, the slower and less predictable reads become, until compaction rewrites the base.
Hudi's `hoodie.table.type` set to `MERGE_ON_READ` chooses it per table. Iceberg chooses it per operation with `write.delete.mode`, `write.update.mode` and `write.merge.mode`, each documented as copy-on-write by default and available as merge-on-read on v2 tables.
The answer most people give
"Merge-on-read merges the new rows into the file when you read it." It merges in memory at query time and leaves the files alone. Only compaction changes the files.
They’ll ask next
Reads on a merge-on-read table get slower every day while writes stay fast. What is missing?
What is compaction in a lakehouse table? What is the difference between minor and major compaction, and when is it triggered?
Why they ask this
Every merge-on-read table and every streaming writer needs it, and the minor against major split is where candidates go vague.
Say this
Compaction is a background rewrite that merges many small files, or base files plus their deltas, into fewer large files and updates the metadata to point at the new ones. Minor compaction merges only the delta or log files; major compaction rewrites base files with the deltas applied. It is triggered by a count of commits, a count of files, a size, or a schedule.
The reasoning
Two problems create the need. Streaming writers commit small files every minute, so a table becomes millions of tiny files that are slow to list and open. Merge-on-read tables accumulate deltas, so every read merges more and more. Compaction pays both costs once, in the background.
Minor compaction merges the log or delete files among themselves without touching the base Parquet. It is quick and reduces the number of deltas a reader must apply. Major compaction reads the base and the deltas, merges by key, applies deletes, and writes a fresh base file. After it, a read is a plain Parquet scan again.
Triggers are configurable. Hudi's `hoodie.compact.inline.max.delta.commits` is documented at 5, with inline compaction off by default and async compaction on for Spark streaming. Delta's auto-compaction targets 128 MB files per its docs, and `OPTIMIZE` bin-packs toward larger ones. Iceberg's `rewrite_data_files` procedure targets `write.target-file-size-bytes`, documented at 512 MB.
Compaction is itself a write, so it commits optimistically and can conflict with other writers. The usual schedule is minor compaction often on bronze, major compaction on a schedule for silver, and no merge-on-read at all in gold.
The answer most people give
"Compaction compresses the files." It merges files; compression is a Parquet codec setting. A compacted file may well be larger than any input.
They’ll ask next
Why should compaction never run continuously, and what signal tells you it is not keeping up?
How does a row-level delete happen in a table format? What are position deletes, equality deletes and deletion vectors?
Why they ask this
GDPR made deletes a daily operation on tables that were designed to be append-only, and the three mechanisms are what makes it possible.
Say this
Under copy-on-write a delete rewrites the file without the row. Under merge-on-read it records the deletion separately: an Iceberg position delete names a data file and a row position; an equality delete names column values to exclude; a Delta deletion vector is a bitmap over a file's rows. Readers apply them at query time until compaction rewrites the file.
The reasoning
A delete must make a row disappear from an immutable file. The copy-on-write answer is simple and expensive: find every file holding a matching row, rewrite each without it, commit the swap. Seven years of files for one user id can be tens of thousands of rewrites.
Iceberg v2's position delete is a small file saying "in `data-0042.parquet`, skip rows 17 and 9,331". It is precise and cheap to apply. An equality delete says "skip any row where `user_id = 42`", without knowing where those rows are. Flink CDC writers produce equality deletes because they cannot afford to look up positions per event; the price is that every read must scan for the values.
Delta's deletion vector is a compact bitmap stored beside the data file and referenced in the log. The file stays as it was; the reader masks the marked rows. `delta.enableDeletionVectors` turns it on per table.
All three leave the physical bytes in place until a rewrite. Proving a deletion for an auditor means compaction or `OPTIMIZE`, then `VACUUM` or snapshot expiry so no old version still references the file.
The answer most people give
"The DELETE statement removed the rows, so the data is gone." The rows are hidden by a delete marker or an old file is still referenced by earlier snapshots. Until cleanup runs, the bytes are still on storage.
They’ll ask next
A Flink CDC job writes equality deletes every minute. Why do reads slow down over a week, and what fixes it?
Reported · 2Compaction & small filesWhy table formats exist
What is data skipping? How does a table format find the few files that hold one row among millions?
Why they ask this
It is the mechanism behind every "how does it find the file" question, and it explains why layout matters.
Say this
Every data file's metadata entry carries statistics: minimum and maximum per column, null counts, record count. A query with a predicate compares it against each file's range and skips files that cannot match. For `order_id = 1500` and a file with min 1 and max 1,000, the file is never opened.
The reasoning
The statistics are collected when a file is written. Delta stores them in the `add` action of the log. Iceberg stores lower and upper bounds per column in the manifest entry. Hudi keeps them in its metadata table's column stats index. None of this needs the file to be opened at query time.
Skipping works best when values are clustered. If `order_id` increases with time and files are written in order, each file covers a narrow range and a point lookup touches one file. If files hold random ids, every file's range covers the whole space and nothing is skipped.
That is why sorting or Z-ordering is a read optimisation: it narrows the ranges. Partitioning is the coarse version of the same idea, skipping whole folders or manifests by the partition value.
Delta's docs note that statistics are collected only for the first columns, up to `delta.dataSkippingNumIndexedCols`, and each nested field counts separately. A wide table may need its filter columns moved forward, or the property raised.
The answer most people give
"The format keeps an index on the primary key." Most do not. Skipping is driven by per-file min and max ranges, which only work when the data is laid out to make ranges narrow.
They’ll ask next
A table is clustered by `order_date` and the query filters on `customer_id`. How much does data skipping help?
Reported · 2Compaction & small filesHidden partitioning & partition evolution
What is Z-ordering? How does liquid clustering differ from Z-order and from partitioning?
Why they ask this
Layout questions are where Delta interviews go deep, and the three options are easy to confuse.
Say this
Z-ordering rewrites files so rows close on several columns at once sit in the same files, which makes data skipping work on all of them. It is run as `OPTIMIZE ... ZORDER BY` and is not incremental. Liquid clustering keeps the same goal but clusters new data as it arrives, without rewriting the whole table, and replaces both Z-order and Hive-style partitioning for most tables.
The reasoning
Sorting by one column narrows that column's range per file and leaves the others random. Z-ordering interleaves the bits of several columns into one sort key, so files are compact in all of them, at some cost to each. Delta's docs say effectiveness drops with every extra column, and to use it on high-cardinality columns that appear in predicates.
Z-order is a full rewrite. It is not idempotent, and new appends are not Z-ordered until the next `OPTIMIZE`. On a table that grows every hour, the table is always partly unordered.
Liquid clustering, `CLUSTER BY` on a Delta table, chooses the clustering columns at creation and clusters incrementally as `OPTIMIZE` runs, touching only new or poorly clustered files. The columns can be changed later without rewriting data, which partitioning cannot do.
Partitioning remains right for very low-cardinality columns that every query filters on, such as a date, and for isolating data that is deleted or retained together. For everything else, clustering gives the skipping without the small-file problem partitions create.
The answer most people give
"Z-order is an index." It is a physical layout. It changes which rows share a file, so that the existing min and max statistics become useful.
They’ll ask next
Why is partitioning by `customer_id` wrong on a 100-million-customer table, and what do you cluster by instead?
What is the target file size in a lakehouse table, and why is it in the hundreds of megabytes? What goes wrong above and below it?
Why they ask this
The number comes up in every small-files conversation, and candidates should know it is a documented default, not folklore.
Say this
Iceberg's `write.target-file-size-bytes` is documented at 512 MB; Delta's auto-compaction targets 128 MB and `OPTIMIZE` goes larger; Hudi's `hoodie.parquet.max.file.size` is about 120 MB. Files that size keep per-file overhead small while still splitting into parallel tasks. Smaller means too many tasks and metadata; larger means too little parallelism and expensive rewrites.
The reasoning
Every file costs a listing entry, an open, a footer read, a metadata row and a task. A terabyte in 2 million 500 KB files spends most of its time on that overhead. The same terabyte in 2,000 files of 512 MB reads at line speed.
Files can also be too large. A 10 GB file is one task for a reader that cannot split it, and under copy-on-write a 1-row update rewrites all 10 GB. Row groups inside Parquet, documented at 128 MB in Iceberg's `write.parquet.row-group-size-bytes`, give readers a unit smaller than the file.
The writer controls size at write time through the engine's partition count, and after the fact through compaction toward the target. Streaming writers always produce small files and rely on compaction.
The right size depends on the engine's split size and the query pattern. Scan-heavy tables lean larger; tables with frequent point updates under copy-on-write lean smaller to limit rewrite cost.
The answer most people give
"Bigger files are always better." Past the split size a bigger file is just a slower task, and under copy-on-write it is a bigger rewrite for every small update.
They’ll ask next
A streaming job writes 3 MB files every minute. What is the file count after 3 months, and what is the fix that does not stop the stream?
What is hidden partitioning in Iceberg? What is partition evolution, and why does it need no rewrite?
Why they ask this
They are Iceberg's two design points that Hive-style tables cannot match, and they are asked by name.
Say this
Hidden partitioning stores a transform of a real column, such as `day(event_time)`, in the partition spec. Writers compute the partition value automatically and readers filtering on `event_time` get pruning without knowing the layout. Partition evolution changes the spec for new data only; old files keep their old spec, and the planner applies each file's own spec.
The reasoning
A Hive table partitioned by date needs an explicit `event_date` column that writers must fill and readers must filter on. Filter on `event_time` instead and nothing is pruned. Iceberg replaces the column with a transform in the spec: `day(event_time)`, `hour`, `month`, `bucket(16, user_id)`, `truncate`.
Because the spec is metadata, Iceberg derives the partition value at write time and translates a predicate on the source column into a predicate on the partition at read time. Users never see or maintain a partition column.
Partition evolution is a metadata change. Switching a table from `day(event_time)` to `hour(event_time)` adds a new spec id. New writes use it. The three years of old files keep spec 0, and a query plans across both, pruning each manifest by its own spec. Nothing is rewritten.
Hive-style tables cannot do this: changing the partition column means rewriting every file into new directories. Delta's answer to the same need is liquid clustering, where the clustering columns can change without a rewrite.
The answer most people give
"Partition evolution rewrites the old data into the new layout." It does not touch old files. Mixed specs coexist, which is the whole point.
They’ll ask next
After evolving from daily to hourly partitions, a query filters on one hour. What happens to the old daily files?
What is time travel in a table format? What does `VERSION AS OF` actually read, and what limits how far back it works?
Why they ask this
It is the feature everyone names and few can explain in terms of files, which is what makes its limits a follow-up.
Say this
Time travel queries the table as it was at an earlier version or timestamp. The engine reads that version's metadata and the data files it referenced, which still exist because files are immutable and not yet cleaned up. It works only as far back as the log is retained and the old files have not been vacuumed.
The reasoning
Delta's docs give the syntax: `SELECT * FROM t VERSION AS OF 41` or `TIMESTAMP AS OF '2026-10-01'`, and `.option('versionAsOf', 41)` on a DataFrame read. Iceberg exposes snapshots the same way, with `FOR VERSION AS OF` and snapshot ids. Hudi reads a table as of an instant on its timeline.
The mechanism is a different starting point in the metadata. Version 41's log entries, or snapshot 41's manifest list, name the files that were the table then. Those files were never modified, only superseded, so reading them gives the exact old state.
Two things cut it off. Log retention: Delta's `delta.logRetentionDuration` is documented at 30 days, so older versions are not reconstructable. File retention: `VACUUM` in Delta and `expire_snapshots` in Iceberg remove data files no current version references, after which an old version points at files that are gone.
Time travel is also the basis of rollback and of auditing a bad write. Diff the current version against the last known good one, then restore it as a new commit.
The answer most people give
"Time travel keeps a copy of the table per version." Versions share files. Only the files that changed are new, which is why keeping 90 days of history is affordable on a slowly changing table and expensive on a heavily merged one.
They’ll ask next
You ran `VACUUM` with the default retention yesterday. Which versions can you still time-travel to?
What does `VACUUM` do in Delta Lake, and what does `expire_snapshots` do in Iceberg? Why is the default retention a week, and what are orphan files?
Why they ask this
Storage bills and broken time travel both come from this operation, and the retention default has a safety reason candidates should know.
Say this
Both delete data files that no retained version references. Delta's `VACUUM` removes files logically deleted longer ago than `delta.deletedFileRetentionDuration`, documented at 7 days; Iceberg's `expire_snapshots` drops snapshots older than `history.expire.max-snapshot-age-ms`, documented at 5 days, and then their unreferenced files. The window exists so a reader or writer still using an old version does not lose its files mid-job.
The reasoning
When a commit removes a file from the table, the bytes stay on storage. That is what makes time travel and concurrent readers work. It is also why a table with hourly merges grows to many times its live size unless something deletes the old files.
`VACUUM` walks the table directory and deletes files that are no longer referenced and were removed before the retention threshold. The Delta docs say data files are deleted only by `VACUUM` and that it does not delete log files. Running it with a short retention is dangerous: a job that started reading version 40 an hour ago still needs 40's files.
Iceberg separates the two steps. `expire_snapshots` removes old snapshots from the metadata, keeping at least `history.expire.min-snapshots-to-keep`, documented at 1, then deletes the data and manifest files only those snapshots referenced. Metadata files themselves are capped by `write.metadata.previous-versions-max`, documented at 100.
Orphan files are a separate problem: files written by a job that failed before committing, which no snapshot ever referenced. Neither operation above finds them, because they are not in any metadata. Iceberg's `remove_orphan_files` and a periodic listing against the metadata are the cleanup.
The answer most people give
"`VACUUM` deletes the old versions." It deletes files. The log entries for old versions remain until log retention removes them; they just point at files that no longer exist.
They’ll ask next
A table is 20 TB live and 120 TB on S3 after a year of hourly merges and some failed jobs. Which two operations were missing?
What is a change data feed on a lakehouse table? How does an incremental read differ from a full scan?
Why they ask this
It is how a silver table feeds a gold table without re-reading everything, and it turns the lakehouse into a source of CDC rather than only a sink.
Say this
A change data feed records row-level changes per commit, with the operation type, so a downstream job can read only what changed since a version. Delta's `delta.enableChangeDataFeed`, documented as off by default, writes change files; Iceberg exposes changes between snapshots through its changelog view; Hudi's incremental query returns rows changed since a given instant.
The reasoning
A full scan reads the whole table every run. An incremental read asks "what changed since version 41" and gets inserts, updates and deletes with a marker on each. The downstream job applies those to its own table with a merge, at a cost proportional to the change, not the table.
Delta writes change files into a `_change_data` folder on commits that update or delete rows when the feed is enabled, and derives inserts from the added files. A reader uses `readChangeFeed` with a starting version or timestamp and gets `_change_type`, `_commit_version` and `_commit_timestamp` columns.
Iceberg's `create_changelog_view` procedure builds a view of net changes between two snapshots, and incremental append scans read only files added in a snapshot range. Hudi's incremental query mode reads rows with a commit time after the one you pass, which is how a 15-minute `fact_trips` build reads only new trips.
The feed is only as retained as the history behind it. Once `VACUUM` or snapshot expiry removes the versions, the changes are gone and the consumer must fall back to a full read.
The answer most people give
"Every table has a change feed; just read the log." The log says which files changed, not which rows. Row-level change data has to be written on purpose, and in Delta it is off by default.
They’ll ask next
A consumer falls behind by 10 days and the feed's history is 7 days. What does it do next?
Which schema changes are safe in a table format, and which are not? Why does Iceberg track columns by id instead of by name?
Why they ask this
Schemas change constantly, and the column-id design is the one detail that makes a rename or a re-add safe in Iceberg and risky in older formats.
Say this
Adding a nullable column, widening a type such as int to long, and renaming or reordering columns are safe metadata changes. Dropping a required column, narrowing a type, or changing a type incompatibly is not. Iceberg gives every column a unique id and reads files by id, so a rename or a dropped-then-re-added column never maps old data to the wrong field.
The reasoning
Old files are immutable, so a schema change can only be a rule for interpreting them. Adding a column means old files return null for it. Widening int to long means old int values are read as long. Those are safe because every old file can still be read under the new schema.
Dropping a required column breaks readers that expect it. Narrowing a type can lose values. Changing a string to an int fails on old files that hold non-numeric strings. Those need a migration: a new column, a backfill, a cutover.
Name-based formats have a trap. Drop `discount`, later add a new `discount` with a different meaning, and old files' `discount` values flow into the new column. Iceberg assigns each column an id at creation and stores ids in the file metadata. The new `discount` has a new id, so old files correctly return null for it.
Delta enforces the schema on write and rejects a mismatched append unless `mergeSchema` or `overwriteSchema` is set, and uses column mapping to allow renames and drops. Delta's docs call session-wide automatic merging not recommended.
The answer most people give
"Turn on schema merging and every change is safe." Merging accepts additive changes. It cannot make a type change or a dropped required column safe for old readers.
They’ll ask next
A producer renames `amount` to `amount_gross`. What does each format do with the next read of old files?
What are Hudi's two table types and three query types? What does each query type read?
Why they ask this
Hudi interviews ask these by name, and the query types are what make merge-on-read usable in practice.
Say this
The table types are `COPY_ON_WRITE` and `MERGE_ON_READ`, documented with copy-on-write as the default. The query types are snapshot, which merges base and log files for the latest view; read-optimized, which reads only base files and is faster but can be stale on merge-on-read tables; and incremental, which returns rows changed since an instant.
The reasoning
On a copy-on-write table, snapshot and read-optimized queries are the same thing, because there are no log files. The difference only appears on merge-on-read.
A snapshot query on a merge-on-read table reads each file group's base file and applies its log files. It sees every committed update, at the cost of a merge per read. Hudi's docs document `hoodie.datasource.query.type` with `snapshot` as the default.
A read-optimized query reads base files only. It is as fast as a Parquet scan and it does not see updates that are still only in logs, so it shows the table as of the last compaction. Dashboards that can accept minutes of staleness use it.
An incremental query takes a begin instant and returns rows that changed after it, using the timeline to find the commits and the files they touched. It is how downstream tables are fed cheaply.
The answer most people give
"Read-optimized is just a faster snapshot query." It is faster because it skips the logs, which means it is stale by exactly the uncompacted updates. The staleness is the trade, not a side effect.
They’ll ask next
A dashboard on a merge-on-read table shows numbers an hour old while a notebook shows current ones. Which query type is each using?
Reported · 2Hudi indexing & record keysHudi timeline & file groups
What is a Hudi index, and what is a file group? What does the index do during an upsert?
Why they ask this
The index is what makes Hudi's upserts fast, and its growth is what makes them slow, so it is a reliable senior question.
Say this
A file group is the set of files that hold one slice of a Hudi table's records: a base file plus its log files, identified by a file id. The index maps a record key to the file group that holds it. During an upsert, Hudi looks up each incoming key to tag it as an insert or an update, so it rewrites only the file groups that actually hold changed records.
The reasoning
Hudi needs to know where a record lives before it can update it. Without an index, an upsert would have to scan the whole table to find matching keys. The index answers "which file group holds `order_id = 42`" from metadata.
Index types trade lookup cost against write cost. Bloom uses bloom filters and key ranges stored in file footers; it suits keys that are mostly new and roughly ordered. Simple joins incoming keys against the table's keys; Hudi's docs name it the default on Spark. Bucket hashes keys to a fixed set of file groups with no lookup at all. The record-level index in the metadata table gives a direct key-to-file map.
During an upsert, the batch is first deduplicated by key using the ordering field. Each key is looked up in the index. The batch is then split: updates go to their existing file groups, and inserts are packed into new or small file groups. Then each affected group is written, as a rewrite under copy-on-write or as a log file under merge-on-read.
Random keys such as UUIDs defeat bloom and range pruning, because no file can be ruled out, so lookups grow with table size. That is the usual reason an upsert that took 3 minutes at 500 million rows takes 40 at 5 billion.
The answer most people give
"Hudi keeps a primary-key index like a database." Only the record-level index comes close. The default indexes are lookups built from file statistics, and their cost depends on how the keys are distributed.
They’ll ask next
Keys are random UUIDs and upserts have slowed tenfold. Which index type removes the lookup entirely, and what does it give up?
What is a catalog in a lakehouse? What does it store, and what is the difference between the Hive metastore, AWS Glue and the Iceberg REST catalog?
Why they ask this
Every format needs one, and the choice decides which engines can share a table and whether commits are atomic.
Say this
A catalog maps a table name to the location of its current metadata and performs the atomic pointer swap that makes a commit visible. The Hive metastore and AWS Glue store the pointer as a table property in a shared metastore; the Iceberg REST catalog is an HTTP protocol that any engine can speak, so one service owns commits and access for all of them.
The reasoning
A table format's files describe the table, but something must say where the current metadata is. For Delta that is the `_delta_log` directory itself, so Delta needs a catalog mainly for names and permissions. For Iceberg the catalog is essential: it holds the pointer to the current `metadata.json` and the compare-and-swap that commits a new one.
The Hive metastore was built for Hive tables and stores Iceberg's pointer as a property. It works, but it is a database every engine must reach directly. AWS Glue plays the same role as a managed service in one cloud.
The Iceberg REST catalog specification turns the catalog into an API. Engines call it to load tables, commit, and increasingly to get credentials, so access control sits in one place and no engine needs database credentials. Nessie and Polaris are implementations, and Unity Catalog and Glue now speak it too.
The catalog is also where multi-engine interoperability lives or dies: Spark, Trino, Flink and a warehouse can share a table only if they agree on the catalog that names it.
The answer most people give
"The catalog stores the table's schema." The schema is in the format's own metadata. The catalog stores where that metadata is, and owns the commit.
They’ll ask next
Two engines commit to the same Iceberg table through different catalogs. What breaks?
How does a `MERGE` or upsert work on a lakehouse table, given that Parquet files cannot be modified?
Why they ask this
It is the operation the whole format exists for, and the file-level answer separates understanding from usage.
Say this
The engine joins the incoming rows to the table to find which existing files hold matching keys, reads only those files, applies updates and deletes and appends inserts, writes new files, and commits a version that removes the old files and adds the new. Under merge-on-read it writes delta files instead of rewriting bases.
The reasoning
A `MERGE INTO target USING source ON key` first scans the target for files that might contain source keys, using statistics to skip the rest. Only the touched files are read into memory.
For each touched file, the engine rewrites it with matched rows updated or removed and, in the same job, writes new files for the unmatched source rows. Nothing is edited in place; the result is a set of new files and a list of old files to retire.
The commit records removed and added files atomically. A reader on the old version keeps its files; a reader after the commit sees the new ones. If the job fails before commit, the new files are orphans and the table is untouched.
The cost drivers are the number of files touched and their size. Clustering the target on the merge key, limiting the merge to recent partitions, and keeping files at the target size all make merges fast. High-frequency small merges favour merge-on-read instead.
The answer most people give
"MERGE updates the matching rows in the Parquet file." Parquet cannot be updated. The file is rewritten, or a delta is written beside it, and the old file is retired in the metadata.
They’ll ask next
A nightly MERGE touches every file in a 2 TB table although only 1 percent of rows change. What about the layout causes that?
What is a lakehouse? How is it different from a data lake and from a data warehouse?
Why they ask this
It is the definition every table-format interview opens with.
Say this
A lakehouse combines data lake storage, open files on cheap object storage, with warehouse capabilities: ACID transactions, schema enforcement, time travel and fast SQL, provided by a table format. A lake alone has the storage without the guarantees; a warehouse has the guarantees in a closed store.
The reasoning
The lake gave cheap, open, unlimited storage and no reliability: no transactions, no schema, no safe updates. The warehouse gave reliability and performance in a proprietary engine that owns the data. Teams ended up copying data between the two.
A table format on the lake closes most of the gap. Delta, Iceberg and Hudi add a metadata layer that gives atomic commits, snapshot isolation, schema enforcement and statistics. The lake behaves like a table store while the files stay open Parquet.
That lets one copy of the data serve many engines: Spark for ETL, Trino or a warehouse for BI, Flink for streaming, Python for ML. The warehouse becomes one reader among several instead of the owner.
What a lakehouse still lacks relative to a warehouse is the integrated engine: compaction, statistics and clustering are jobs you run, not things that happen.
The answer most people give
"A lakehouse is a data lake with SQL on top." SQL on files existed before. The table format's transactions and metadata are what make it a lakehouse.
They’ll ask next
Name the guarantee a lakehouse table has that a folder of Parquet does not, and the operation that depends on it.
EvergreenReported · 2Copy-on-write vs merge-on-readCompaction & small files
Which medallion layer should use copy-on-write and which should use merge-on-read? Why?
Why they ask this
It is the practical form of the COW against MOR question, and the one that shows the candidate has placed a table in a real pipeline.
Say this
Merge-on-read in bronze and in CDC-fed silver tables, where writes are frequent and small and read latency can tolerate a merge. Copy-on-write in gold and in any table that dashboards scan, where reads dominate and must be fast and predictable. Compaction on the merge-on-read tables keeps their reads bounded.
The reasoning
Bronze takes a stream of small commits: events every minute, CDC changes every few seconds. Rewriting base files for each would be prohibitive. Merge-on-read makes those writes cheap, and bronze readers are batch jobs that can afford a merge.
Silver tables fed by CDC with many updates are the same case. If silver is rebuilt in batch from bronze once an hour, copy-on-write is fine, because the write is one large rewrite anyway.
Gold is read thousands of times by BI tools and updated rarely, in scheduled batches. Copy-on-write gives those readers plain Parquet with full statistics and no merge, and the occasional rewrite is a price paid once a day.
Whatever the layer, a merge-on-read table needs compaction scheduled against its read SLA. A copy-on-write table needs its merges aligned to partitions or clustering, so each update touches few files.
The answer most people give
"Use merge-on-read everywhere because writes are cheaper." Reads then pay a merge on every query, and the dashboards are the thing people notice.
They’ll ask next
A gold table gets 5 million small updates an hour from a stream. What is the design, if copy-on-write is too slow and merge-on-read is too slow to read?
What is Apache Parquet? What are row groups, column chunks and the footer?
Why they ask this
Every table format sits on Parquet, and its internal structure is what makes skipping and pushdown possible.
Say this
Parquet is a columnar binary file format with a schema. A file is split into row groups, each holding a slice of rows. Within a row group, each column is stored as a column chunk with its own encoding and compression. The footer at the end of the file holds the schema and per-chunk statistics, so a reader can decide what to read before reading it.
The reasoning
Row groups make a file splittable: a reader can process row group 3 without the others, which is how one large file becomes several tasks. Iceberg documents `write.parquet.row-group-size-bytes` at 128 MB.
Within a row group, a column chunk holds all values of one column, divided into pages. Because the values are one type and often similar, each chunk picks an encoding that fits: dictionary for repeated strings, run-length for sorted or clustered values, delta for increasing numbers. A codec such as Snappy or Zstd compresses on top.
The footer is read first. It carries the schema, the row group boundaries, and for each column chunk the min, max and null count. A query for `region = 'EU'` skips any row group whose region range excludes it. A query for 3 of 40 columns reads only 3 chunks per row group.
Parquet files are immutable and written in batches, which is why streaming writers produce many small files and why updates need a table format above the file.
The answer most people give
"Parquet is compressed CSV." It is columnar, typed and self-describing, with statistics. The compression is a consequence of the layout, not the point.
They’ll ask next
Why does a predicate on a sorted column skip more row groups than the same predicate on a random column?
What does ACID mean for a lakehouse table? Which of the four letters is the hard one on object storage?
Why they ask this
ACID is quoted on every slide, and the question checks whether the candidate can say what each letter means for files.
Say this
Atomic: a commit adds and removes files all at once or not at all. Consistent: every reader sees a valid version with its schema enforced. Isolated: readers see a snapshot and writers detect conflicts at commit. Durable: once committed, the metadata and files persist. Atomicity is the hard one, because object stores have no rename or multi-object transaction, so the format must make one small write the commit point.
The reasoning
Atomicity on a file system can lean on rename. Object stores do not rename, so Delta makes the commit the creation of one log file, which must be atomic and exclusive. On S3 that needs a coordination service or a conditional put. Iceberg makes the commit the catalog's pointer swap, which is atomic in the catalog's own store.
Consistency is schema enforcement and invariants: a write that violates the schema or a constraint is rejected, so no version is ever malformed.
Isolation is snapshot isolation. A reader fixes a version at the start and reads only its files. Writers work optimistically and check for conflicts when they publish, so two jobs never corrupt each other, though one may fail.
Durability is inherited from the object store's durability once the commit is written. Before the commit, the new files are just bytes nobody references.
The answer most people give
"The lake is ACID because S3 is strongly consistent." Consistency of a single object is not a transaction across many. The format supplies the transaction.
They’ll ask next
What did Delta on S3 need, before conditional writes existed, to make two writers' commits safe?
What is a checkpoint file in a Delta table? Why does Delta write one?
Why they ask this
A one-liner that tests whether the candidate knows how a reader loads a table with 10,000 commits.
Say this
A checkpoint is a Parquet file in `_delta_log` that holds the complete table state at a version: every active file with its statistics, plus the schema and protocol. Delta writes one every 10 commits by default so a reader loads the latest checkpoint and replays only the few JSON commits after it, instead of all of them.
The reasoning
The log is a list of JSON commits, and the table state is the result of replaying them. After thousands of commits, replaying from zero would take longer than the query. The checkpoint is the saved result of that replay.
A reader finds the latest checkpoint through the `_last_checkpoint` file, loads it, then applies the commits with higher version numbers. The Delta docs note that each time a checkpoint is written, Delta cleans up log entries older than the retention interval.
Checkpoints are also where the cost of a huge table shows: a table with millions of files has a large checkpoint, which is why file count matters even for metadata. Newer Delta versions support multi-part and v2 checkpoints to keep this manageable.
Checkpoints never contain data rows. They are metadata about files, the same actions as the JSON commits in a columnar form.
The answer most people give
"A checkpoint is a backup of the table." It holds the file list, not the rows, and it is derived entirely from the log.
They’ll ask next
Why does a table with 5 million small files have slow planning even on a trivial query?
What does `OPTIMIZE` do on a Delta table? Is it safe to run twice?
Why they ask this
It is the single command behind most small-file fixes, and whether it is idempotent is the follow-up.
Say this
`OPTIMIZE` compacts small files into larger ones by bin-packing, rewriting data files and committing a version that swaps them, without changing the rows. Delta's docs call bin-packing idempotent: a second run on the same data does nothing. With `ZORDER BY` it also reorders rows, which is not idempotent.
The reasoning
Compaction is a write like any other: new large files are written, the old small files are marked removed in the log, and readers after the commit see fewer files. The old files stay until `VACUUM`.
Delta's docs say bin-packing is idempotent, so running `OPTIMIZE` on an already compacted table is a no-op. With `ZORDER BY`, the docs say it is not idempotent, because the whole clustering is recomputed and files are rewritten again.
`OPTIMIZE` can be scoped with a `WHERE` on partition columns, so a nightly job compacts only yesterday's partition rather than the whole table. Auto-compaction and optimized writes do a lighter version at write time, targeting 128 MB per the docs.
Because it is a transaction, `OPTIMIZE` can conflict with concurrent writers on the same files. Scheduling it against quiet partitions avoids retries.
The answer most people give
"`OPTIMIZE` frees storage." It adds files; the small ones are only retired in the log. Storage is freed by the `VACUUM` that follows.
They’ll ask next
After `OPTIMIZE` the table is 2x its live size on disk. What has not happened yet?
What is snapshot isolation in a table format? What does a long-running query see while writers commit?
Why they ask this
It is the guarantee behind "readers never see a half-written table", asked flat.
Say this
A reader fixes the table's version when it starts and reads only the files that version references, for its whole run. Commits that land later create new versions with new files and do not change the old files, so the long query sees a consistent table as of its start, even while writers add, update and delete.
The reasoning
The guarantee rests on immutability. A commit never edits a file; it writes new files and a new metadata version. A reader holding version 41's file list is unaffected by version 42, because 41's files still exist unchanged.
That is also why vacuum has a retention window. If a cleanup deleted files the moment they were superseded, a running query on version 41 would fail mid-scan when its files vanished.
Writers get the same isolation among themselves: each works from the version it read, and the commit step detects whether another writer changed the files it depends on.
The practical limit is that a query sees the table as of its start, not its end. A dashboard refreshed during a load shows the previous load, which is correct, and the next refresh shows the new one.
The answer most people give
"Readers are blocked until the writer finishes." Nothing blocks. The reader simply keeps the version it started with.
They’ll ask next
A 2-hour report starts at 09:00 and a load commits at 09:30. Which data does the report contain?
What is Apache XTable, and what is Delta UniForm? When would you use them?
Why they ask this
Multi-format interoperability is a current question, and the two tools solve it in different directions.
Say this
Both let one set of Parquet files be read as more than one table format by generating the other format's metadata. Delta UniForm writes Iceberg or Hudi metadata alongside the Delta log as part of each commit. Apache XTable is a separate tool that translates metadata between Delta, Iceberg and Hudi on a schedule. Use them when engines that only speak one format must read a table written in another.
The reasoning
The data files are the same Parquet in all three formats. Only the metadata differs. So a translator can read Delta's log and write an Iceberg manifest tree pointing at the same files, and an Iceberg-only engine can then read the table.
UniForm is built into Delta: enable it on a table and each Delta commit also produces Iceberg metadata, which a catalog can expose. It is one-directional, Delta as the source of truth, and the Iceberg view may lag a commit.
XTable, formerly OneTable, runs outside any engine. It syncs metadata in any direction between the three formats, incrementally, on a schedule or after a job. It is the choice when the writer cannot be changed or when the source of truth is not Delta.
Neither makes the table writable from the second format safely. They are read-side bridges, and the translated metadata has its own retention and sync lag to manage.
The answer most people give
"They convert the table from one format to another." They add a second set of metadata over the same files. No data is converted or copied.
They’ll ask next
Athena needs to read tables that Databricks writes as Delta. Which of the two do you reach for, and why?
EvergreenReported · 2Iceberg vs Hudi vs DeltaUpserts, CDC & deletes
What is the difference between Iceberg format version 1 and version 2?
Why they ask this
It is asked flat and it decides whether a table can do row-level deletes without rewriting files.
Say this
Format v1 supports only copy-on-write: every change rewrites data files. Format v2 adds delete files, position and equality, so a table can do merge-on-read deletes and updates. Iceberg's configuration page documents `format-version` as defaulting to 2 since 1.4.0, and the merge-on-read write modes are available on v2 and above.
The reasoning
Version 1 defined the metadata tree, snapshots, hidden partitioning and schema evolution by column id. Every write produced whole new data files; a one-row delete meant rewriting the file.
Version 2 introduced delete files as first-class members of a snapshot. A manifest can list delete files alongside data files, and a reader applies them. That is what makes `write.delete.mode`, `write.update.mode` and `write.merge.mode` settable to merge-on-read, which the docs restrict to v2 and above.
Upgrading a table from v1 to v2 is a metadata change. Existing files are untouched; new writes may use delete files. Readers must be on an engine version that understands v2, which is why the version is a reserved table property rather than something a writer sets freely.
Version 3 continues the line with row lineage, deletion vectors and new types, which is where the formats are converging.
The answer most people give
"v2 is just newer and faster." The concrete difference is delete files. A v1 table cannot do merge-on-read at all.
They’ll ask next
A Flink CDC job must write to an Iceberg table created years ago as v1. What has to happen first?
What is a bloom filter in a Parquet file or a Hudi index? What can it say, and what can it not say?
Why they ask this
It is the structure behind point-lookup pruning, and the false-positive detail is the part interviewers listen for.
Say this
A bloom filter is a small bit array built from the values in a file or column chunk. Asked whether a value is present, it answers "definitely not" or "maybe". It never gives a false negative, so a file whose filter says no can be skipped safely; a yes means the file must be read and may turn out not to contain the value.
The reasoning
Min and max statistics prune ranges, but a point lookup on a high-cardinality column inside a wide range still has to open the file. A bloom filter answers membership instead: hash the value into a few positions and check whether every bit is set.
Parquet can store a bloom filter per column chunk, which readers consult after the footer statistics. Hudi's bloom index stores filters in file footers and uses them, with key ranges, to decide which file groups an upsert must probe.
The trade is size against false-positive rate. More bits per key means fewer false positives; the documentation for most implementations targets around 1 percent at about 10 bits per key. A false positive costs one unnecessary file read, never a wrong answer.
Bloom filters help when keys are selective and files are many. On random UUID keys they still prune, which is why Hudi reaches for them. But their size grows with the number of keys, and they cannot answer range predicates at all.
The answer most people give
"A bloom filter tells you which file has the row." It tells you which files cannot have it. The remaining files may or may not, and must be read.
They’ll ask next
A lookup on a UUID key touches 400 files out of 10,000 after bloom pruning, and the key is in one. Why so many, and what reduces it?