Open table formats in practice: what actually differs between Iceberg, Hudi and Delta Lake
All three formats give you ACID commits, time travel and schema evolution, so a feature checklist will not tell you which to pick. What separates them is how each one tracks table state, how each one applies a row-level update, and what each one asks you to operate.
- Why do these formats exist at all?
- Which files make up this table right now?
- Architecture: what does each one put on disk?
- What happens when you update one row?
- What does the same table look like in all three?
- Where do they really diverge?
- What do you have to operate?
- Which one should I pick?
- Can I avoid choosing?
- How do I know it is working?
- Frequently asked questions
- Conclusion
- References
TL;DR
- All three solve the same original problem: a directory of Parquet on object storage has no atomic commit, no row-level update and no cheap way to know which files are live. Each adds a metadata layer that answers “which files make up the table right now”.
- The real difference is the shape of that metadata. Iceberg keeps a tree of immutable snapshots behind one atomic pointer, Delta an ordered log of JSON commits with periodic Parquet checkpoints, and Hudi a timeline of instants plus an index mapping a record key to a file group.
- That shape decides everything downstream. Hudi’s index makes it the natural fit for high-frequency upserts by primary key; Iceberg’s and Delta’s per-commit file lists make them the natural fit for large snapshot-oriented writes.
- Copy-on-write versus merge-on-read is a choice in all three, not a property of one. Iceberg defaults
write.delete.mode,write.update.modeandwrite.merge.modetocopy-on-write, Delta needsdelta.enableDeletionVectors, and Hudi asks at table creation.- You can defer the decision. Apache XTable converts metadata between all three over one copy of the Parquet, so the choice of writer no longer dictates the choice of reader.
Every comparison of these three formats hands you the same table. ACID commits: yes, yes, yes. Time travel: yes, yes, yes. Schema evolution: yes, yes, yes. Every row ticks three times, and you finish the article knowing roughly as much as when you started.
That checklist is not wrong. It is just describing the part where the formats converged. They all started from the same broken thing and they all fixed it. What none of those tables tell you is the part where they did not converge: the shape each one chose for its metadata, which is decided once, early, and then quietly determines how expensive your updates are, how your writes behave when two of them collide, and how much maintenance you inherit.
So this post skips the checklist. One question separates the three, and once you can answer it for each format, most of the rest follows.
The baseline is the current Spark-compatible release of each: Iceberg 1.11.0,
Hudi 1.2.0 and Delta Lake 4.4.0. Versions are named throughout, because
several of the defaults below are exactly the kind of thing that moves between
releases. By the end you should be able to look at a table directory and name the
format that wrote it, explain what each one does when you update a single row,
and choose from the shape of your own workload.
Why do these formats exist at all?
The architecture they replaced was a directory of Parquet files on object storage with a Hive Metastore partition list on top. It works until you need one of five things.
There is no atomic commit. A job that writes 400 files and fails at file 250 leaves 250 files that queries can already see. The usual defence is writing to a staging path and renaming, which on S3 is a copy rather than a rename, and is not atomic across many files.
There is no row-level update. Correcting one row means rewriting the partition that contains it. An erasure request for one customer becomes a rewrite of every partition that customer appears in.
Listing is the query. To plan a scan the engine lists directories. At a few
hundred partitions this is invisible. At a hundred thousand it is most of your
planning time, and on object storage each LIST is a paginated API call.
Readers see torn state. Without a commit protocol, a reader that starts while a writer is mid-flight sees some new files and some old ones, and there is no way to ask for the table as it stood ten minutes ago.
Schema changes are a convention, not a contract. Renaming a column in the metastore does not rename it in the files, and positional column matching means adding a column in the middle silently shifts every value to the right.
All three answer the same way: stop asking the filesystem what the table contains, and keep an authoritative record instead. Every difference between them follows from the shape they chose for that record.
Which files make up this table right now?
That is the question. Ask it of each format and you get three structurally different answers.
flowchart TB
subgraph ICE["Iceberg snapshot tree"]
direction LR
C1["catalog<br/>pointer"] --> M1["v42.metadata.json"]
M1 --> L1["manifest list"]
L1 --> F1["manifest<br/>*-m0.avro"]
L1 --> F2["manifest<br/>*-m1.avro"]
F1 --> D1["data files"]
F2 --> D2["data files"]
end
subgraph DEL["Delta ordered commit log"]
direction LR
K1["042.checkpoint<br/>.parquet"] --> K2["043.json"]
K2 --> K3["044.json"]
K3 --> K4["045.json"]
K4 --> DD["live files =<br/>checkpoint + replay"]
end
subgraph HUD["Hudi timeline and index"]
direction LR
T1["timeline<br/>instants"] --> T2["file groups"]
IX["record index"] --> T2
T2 --> T3["file slice =<br/>base + log files"]
end
%% Invisible links stack the three panels instead of letting mermaid
%% place them side by side, which shrinks every label.
ICE ~~~ DEL
DEL ~~~ HUD
Iceberg answers with a tree. A catalog holds one pointer to the current
metadata.json. That file names the current snapshot. The snapshot points at a
manifest list, which points at manifests, which list data files with their
partition values and column statistics. A commit writes a new metadata.json and
atomically swaps the catalog pointer. Nothing is ever mutated, so an old snapshot
stays readable as long as it is retained.
Delta answers with a log. The history is an ordered sequence of numbered JSON
files in _delta_log, each recording add and remove actions, and the live
file set is the result of replaying them. Because replaying 200,000 commits would
be slow, Delta periodically writes a checkpoint: a Parquet file holding complete
state at one version, so readers replay only from there.
Hudi answers with a timeline plus an index. The timeline is a directory of instants, each an action with a state. Data is organised into file groups, and a file group’s current contents are a file slice: a base file plus any log files written against it. Uniquely among the three, Hudi also keeps an index mapping a record key to the file group that holds it, which is what lets it locate a single record without scanning.
| Iceberg 1.11.0 | Delta Lake 4.4.0 | Hudi 1.2.0 | |
|---|---|---|---|
| State record | Immutable snapshot tree | Ordered JSON commit log | Timeline of instants |
| Commit mechanism | Atomic swap of a catalog pointer | Atomic creation of the next numbered log file | Atomic creation of a completed instant |
| Read planning | Walk manifests, prune on stats | Checkpoint plus log replay | Timeline plus file slices |
| Record identity | None required | None required | Record key, required |
| Key to file lookup | Not available | Not available | Index, several types |
| Scales planning by | Manifest pruning | Checkpoint frequency | Metadata table |
One row in that table carries more weight than the rest, and it is record identity. Iceberg and Delta describe a table as a set of files; neither has a notion of “the row with this primary key”. Hudi requires a record key at table creation and maintains an index over it.
That single decision is why Hudi is the natural fit for a change-data-capture (CDC) stream keyed by primary key, where each message is an insert, update or delete of one known row, and why Iceberg and Delta are the natural fit for snapshot-oriented batch writes. Everything below is detail on top of it.
Architecture: what does each one put on disk?
Recognising a format from its directory listing is a genuinely useful skill, so
here is what each one writes. Same table throughout: trips, ride events
partitioned by city.
Iceberg
s3a://lakehouse-prod/warehouse/trips/
├── metadata/
│ ├── v1.metadata.json
│ ├── v2.metadata.json <- current, per the catalog
│ ├── snap-7241925443479918015-1-a1c2....avro <- manifest list
│ ├── a1c2f3b4-....-m0.avro <- manifest
│ └── a1c2f3b4-....-m1.avro
└── data/
├── city_id=sf/00000-0-a1c2f3b4-....parquet
└── city_id=nyc/00000-1-a1c2f3b4-....parquet
Two things are worth noticing. The data/ directory is laid out by partition for
human convenience, but the engine never relies on it: partition values are read
from the manifests, which is what makes partition evolution possible. And nothing
in the table itself says which metadata.json is current. That is the catalog’s
job, which is why Iceberg without a catalog is only half a table.
FileContent in 1.11.0 enumerates what a manifest entry can describe: DATA,
POSITION_DELETES, EQUALITY_DELETES, DATA_MANIFEST and DELETE_MANIFEST.
The two delete kinds are how Iceberg represents row-level deletions without
rewriting data.
All of this is queryable, which is the fastest way to understand a table you did
not create. MetadataTableType at 1.11.0 lists sixteen: entries, files,
data_files, delete_files, history, metadata_log_entries, snapshots,
refs, manifests, partitions, position_deletes, and the all_ variants
all_data_files, all_delete_files, all_files, all_manifests and
all_entries. SELECT * FROM prod.trips.snapshots is usually the first thing
worth running.
Delta Lake
Delta’s protocol specification gives this layout, and the filenames are exact:
s3a://lakehouse-prod/warehouse/trips/
├── _delta_log/
│ ├── 00000000000000000042.json
│ ├── 00000000000000000042.checkpoint.parquet
│ ├── 00000000000000000043.json
│ ├── 00000000000000000044.json
│ ├── 00000000000000000045.json
│ └── _last_checkpoint
├── _change_data/
│ └── cdc-00000-924d9ac7-....snappy.parquet
├── deletion_vector-0c6cbaaf-....bin
└── city_id=sf/part-00000-3935a07c-....snappy.parquet
The version number is zero-padded to 20 digits, which is what makes a plain
lexicographic LIST return commits in version order. _last_checkpoint is a
small pointer file so a reader does not have to list the whole log to find the
newest checkpoint. _change_data holds change-data-feed files, and
deletion_vector-*.bin files hold bitmaps of logically deleted rows.
The commit protocol follows directly from the naming: commit N+1 is the file
...0000N+1.json, and the writer that successfully creates that exact filename
wins. On a store with atomic put-if-absent that is clean mutual exclusion. On
plain S3 it historically was not, which is why Delta on S3 with multiple writers
needs a commit coordinator.
Hudi
Hudi 1.x moved the timeline into its own directory, so a 1.x table looks
different from the 0.x tables most tutorials show. hoodie.timeline.path
defaults to timeline and hoodie.timeline.history.path to history:
s3a://lakehouse-prod/warehouse/trips/
├── .hoodie/
│ ├── hoodie.properties
│ ├── timeline/
│ │ ├── 20260911093000123.deltacommit.requested
│ │ ├── 20260911093000123.deltacommit.inflight
│ │ ├── 20260911093000123_20260911093004881.deltacommit <- completed
│ │ └── history/ <- archived instants
│ ├── metadata/ <- the metadata table
│ │ ├── files/
│ │ ├── column_stats/
│ │ └── record_index/
│ └── .index_defs/index.json
└── city_id=sf/
├── 8f3a1c92-...-0_0-24-1893_20260911093000123.parquet <- base file
└── .8f3a1c92-...-0_20260911093000123.log.1_0-24-1893 <- log file
An instant moves through three states and the filename changes with it:
.requested, .inflight, then the completed form. A filter written against
.deltacommit will not match a pending write, which is deliberate, because
readers only ever see completed instants.
Hudi 1.2.0’s timeline has twelve action types, and knowing them saves time when
reading a real table: commit, deltacommit, clean, rollback, savepoint,
replacecommit, clustering, compaction, logcompaction, restore,
indexing and schemacommit.
.hoodie/metadata is an internal Hudi table holding file listings, column
statistics and the record index. It exists so planning does not require listing
data directories, which is Hudi’s answer to the same scale problem Iceberg solves
with manifests and Delta with checkpoints.
What happens when you update one row?
This is where the formats are most often described wrongly, so it is worth being precise. Copy-on-write and merge-on-read are strategies available in all three, not a distinguishing feature of one.
Copy-on-write means an update rewrites whole data files: read the file, apply the change, write a new file, mark the old one removed. Reads stay as fast as plain Parquet because there is nothing to reconcile. Writes pay to rewrite files that mostly did not change.
Merge-on-read means an update records the change separately, as a delete marker or a log entry, and the reader reconciles at query time. Writes are cheap. Reads pay a merge, and something must eventually compact the accumulated changes.
Iceberg decides per operation, and the defaults surprise people.
TableProperties in 1.11.0 sets write.delete.mode, write.update.mode and
write.merge.mode all to copy-on-write. A fresh Iceberg table rewrites files
on DELETE, UPDATE and MERGE unless you say otherwise:
ALTER TABLE prod.trips SET TBLPROPERTIES (
'write.delete.mode' = 'merge-on-read',
'write.update.mode' = 'merge-on-read',
'write.merge.mode' = 'merge-on-read'
);
In merge-on-read, Iceberg writes delete files instead of rewriting data. Position
deletes name a data file and the row positions within it, which is precise and
cheap to apply. Equality deletes name column values, which avoids having to know
positions but forces the reader to apply the predicate more widely. Format
version 3 adds deletion vectors, stored as Puffin blobs, which 1.11.0 implements
in DeletionVector.java and its puffin package. On versions: 1.11.0 defaults
new tables to format version 2 (DEFAULT_TABLE_FORMAT_VERSION = 2) but reads and
writes up to version 4 (SUPPORTED_TABLE_FORMAT_VERSION = 4), with row lineage
requiring at least version 3.
Delta uses deletion vectors, gated on a table property. The protocol is
explicit that writers only create new deletion vectors when
delta.enableDeletionVectors is true, and equally explicit that readers must
handle deletion vectors whether or not the property is set, because the table may
already contain them:
ALTER TABLE trips SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true');
Hudi asks at table creation, through the table type. COPY_ON_WRITE rewrites
base files on update. MERGE_ON_READ appends to log files beside the base file,
and a compaction action later merges them into a new base file. Hudi’s version
of the choice is the most consequential of the three, because it also changes
which query types are available.
| Iceberg 1.11.0 | Delta Lake 4.4.0 | Hudi 1.2.0 | |
|---|---|---|---|
| Granularity of the choice | Per operation, per table property | Per table property | Per table, at creation |
| Default | copy-on-write for delete, update and merge |
Copy-on-write until deletion vectors are enabled | Chosen explicitly |
| Merge-on-read artefact | Position deletes, equality deletes, deletion vectors (v3) | deletion_vector-*.bin |
Log files beside the base file |
| Reader must reconcile | Delete files against data files | Deletion vector bitmaps | Log files against the base file |
| Compaction | rewrite_data_files procedure |
OPTIMIZE |
compaction action, inline or async |
The trade is the same sentence in all three cases: merge-on-read buys low write latency at the cost of read-side merge work and a compaction job you must operate. Copy-on-write buys simple, fast reads at the cost of rewriting files that mostly did not change.
What does the same table look like in all three?
One schema throughout: ride events arriving as a CDC stream, keyed by trip_id,
partitioned by city_id, with updated_at deciding which version of a record
wins.
Each format needs its own runtime jar and catalog wiring:
# Iceberg 1.11.0
spark-sql \
--packages org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.11.0 \
--conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions \
--conf spark.sql.catalog.prod=org.apache.iceberg.spark.SparkCatalog \
--conf spark.sql.catalog.prod.type=hive \
--conf spark.sql.catalog.prod.uri=thrift://metastore.internal:9083
# Delta Lake 4.4.0 (Spark 4.x; use delta-spark 3.x for Spark 3.5)
spark-sql \
--packages io.delta:delta-spark_2.13:4.4.0 \
--conf spark.sql.extensions=io.delta.sql.DeltaSparkSessionExtension \
--conf spark.sql.catalog.spark_catalog=org.apache.spark.sql.delta.catalog.DeltaCatalog
# Hudi 1.2.0
spark-sql \
--packages org.apache.hudi:hudi-spark3.5-bundle_2.12:1.2.0 \
--conf spark.serializer=org.apache.spark.serializer.KryoSerializer \
--conf spark.sql.extensions=org.apache.spark.sql.hudi.HoodieSparkSessionExtension \
--conf spark.sql.catalog.spark_catalog=org.apache.spark.sql.hudi.catalog.HoodieCatalog
Creating it
The interesting difference is how much each one needs to be told.
-- Iceberg. No record key. Partitioning is a transform on a column, and the
-- engine hides it from queries, so filters on started_at prune days.
CREATE TABLE prod.trips (
trip_id STRING,
city_id STRING,
rider_id STRING,
fare_amount DECIMAL(10,2),
started_at TIMESTAMP,
updated_at TIMESTAMP
) USING iceberg
PARTITIONED BY (city_id, days(started_at));
-- Delta. No record key either. Liquid clustering replaces partitioning for
-- most tables, and can be changed later without rewriting the layout contract.
CREATE TABLE trips (
trip_id STRING,
city_id STRING,
rider_id STRING,
fare_amount DECIMAL(10,2),
started_at TIMESTAMP,
updated_at TIMESTAMP
) USING delta
CLUSTER BY (city_id, started_at)
LOCATION 's3a://lakehouse-prod/warehouse/trips';
-- Hudi. The record key and the ordering field are part of the table contract,
-- which is what enables key-based upserts and deletes later.
CREATE TABLE trips (
trip_id STRING,
city_id STRING,
rider_id STRING,
fare_amount DECIMAL(10,2),
started_at TIMESTAMP,
updated_at TIMESTAMP
) USING hudi
PARTITIONED BY (city_id)
LOCATION 's3a://lakehouse-prod/warehouse/trips'
TBLPROPERTIES (
type = 'mor', -- log files now, compaction later
primaryKey = 'trip_id',
-- Renamed in 1.x. `preCombineField` still resolves as a registered
-- alternative, and both map to hoodie.table.ordering.fields.
orderingFields = 'updated_at'
);
Upserting a CDC batch
-- Iceberg and Delta: MERGE is the upsert, and you write the matching logic.
MERGE INTO prod.trips t
USING trip_updates s
ON t.trip_id = s.trip_id
WHEN MATCHED AND s.updated_at > t.updated_at THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;
-- Hudi: the record key and ordering field are already declared, so the
-- matching logic lives in the table rather than in the statement.
MERGE INTO trips t
USING trip_updates s
ON t.trip_id = s.trip_id
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;
The difference is not the syntax, it is what runs underneath. Iceberg and Delta plan a join between the incoming batch and the table to find which files hold the matching keys, so the cost scales with how much of the table that join touches. Hudi looks each key up in its index and goes straight to the file group, so the cost scales with the size of the batch. On a small batch against a large table, that difference is the whole ballgame.
Travelling back in time
All three support it. The syntax and the unit differ.
-- Iceberg: by snapshot id or by timestamp
SELECT count(*) FROM prod.trips VERSION AS OF 7241925443479918015;
SELECT count(*) FROM prod.trips TIMESTAMP AS OF '2026-09-10 09:00:00';
-- Delta: by version number or by timestamp
SELECT count(*) FROM trips VERSION AS OF 42;
SELECT count(*) FROM trips TIMESTAMP AS OF '2026-09-10 09:00:00';
-- Hudi: by instant time, or by a plain timestamp. The documented forms are
-- yyyyMMddHHmmssSSS, yyyy-MM-dd HH:mm:ss.SSS and yyyy-MM-dd.
SELECT count(*) FROM trips TIMESTAMP AS OF '20260911093000123';
SELECT count(*) FROM trips TIMESTAMP AS OF '2026-09-10 09:00:00.000';
Reading only what changed
Incremental consumption is where the three diverge most in capability.
-- Iceberg: build a changelog view between two snapshots, then query it.
-- The view gains _change_type, _change_ordinal and _commit_snapshot_id columns.
CALL prod.system.create_changelog_view(
table => 'prod.trips',
changelog_view => 'trips_changes',
options => map('start-snapshot-id', '7241925443479918015'),
identifier_columns => array('trip_id')
);
SELECT trip_id, fare_amount, _change_type, _change_ordinal
FROM trips_changes
ORDER BY _change_ordinal;
-- Delta: change data feed, once enabled on the table
ALTER TABLE trips SET TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true');
SELECT * FROM table_changes('trips', 43, 45);
-- Hudi: a table-valued function, with instants read off the timeline.
CALL show_commits(table => 'trips', limit => 5);
SELECT trip_id, city_id, fare_amount, updated_at
FROM hudi_table_changes('trips', 'latest_state', '20260911093000123');
Hudi’s incremental read is the oldest and most developed of the three, because
incremental consumption was the workload it was built for. Delta’s change data
feed is opt-in per table and has to be enabled before the changes you want to
read. Iceberg’s changelog scan covers appends well and asks for more help with
updates and deletes, which is what the compute_updates and identifier_columns
parameters are for.
Deleting one person’s rows
The erasure case, which is where record identity earns its keep.
-- Iceberg and Delta: a predicate delete. The engine finds the files, then
-- either rewrites them (copy-on-write) or writes delete markers.
DELETE FROM prod.trips WHERE rider_id = 'rider-8814f2';
DELETE FROM trips WHERE rider_id = 'rider-8814f2';
-- Hudi: the same SQL works, and a keyed delete can go straight to the file
-- groups through the index without scanning to find them.
DELETE FROM trips WHERE rider_id = 'rider-8814f2';
In all three the delete is logical until maintenance runs. The data is still in
the old files until copy-on-write rewrites them or compaction merges the markers
away, and the old snapshot remains readable until retention expires it. For a
genuine right-to-erasure obligation the delete is not complete until retention
has expired the versions that still contain the row, which makes
expire_snapshots on Iceberg, VACUUM on Delta and cleaning on Hudi part of the
compliance story rather than housekeeping.
Where do they really diverge?
Two places: what you can change about the table’s shape after the fact, and what happens when two writers meet.
Every format supports adding, dropping, renaming and reordering columns without rewriting data, because all three track columns by an assigned id rather than by position in the file. The edges are where they differ:
| Capability | Iceberg 1.11.0 | Delta Lake 4.4.0 | Hudi 1.2.0 |
|---|---|---|---|
| Add, drop, rename, reorder | Yes | Yes, columnMapping needed for rename and drop |
Yes |
| Type promotion | Yes, within safe widenings | Yes, within safe widenings | Yes, within safe widenings |
| Partition evolution | Yes, old data keeps its old spec | Not the same way, CLUSTER BY changes are the modern path |
Limited |
| Hidden partitioning | Yes, transforms on a column | Liquid clustering plays this role | No, partition path is explicit |
Partition evolution is Iceberg’s clearest structural advantage. Because partition values live in manifests rather than in directory names, Iceberg can change the partition spec and keep reading old data under its original spec. The usual example is a table partitioned by month that grows enough to want daily partitions: in Iceberg that is a metadata change, and files written before it keep working.
Hidden partitioning is the second. A query filtering on started_at prunes
day partitions without the writer ever mentioning a dt column, because the
partition is declared as days(started_at). Tables in the other two formats
typically carry an explicit partition column, and a query that forgets to filter
on it scans everything. Delta’s answer to layout is liquid clustering rather than
partition evolution, declared with CLUSTER BY, which can be changed later and
does not bake the layout into directory paths.
On concurrency, the honest summary is that all three need help beyond their
defaults. Iceberg uses optimistic concurrency: a writer reads the current
metadata, prepares a new snapshot, and commits by swapping the catalog pointer,
which fails if another writer moved it first. Delta relies on creating the
next numbered log file, so two writers both attempting
...00000000000000000046.json means one must lose, which requires put-if-absent
from the storage layer; the catalogManaged table feature in 4.4.0 reflects the
move toward catalogs owning commits. Hudi ships optimistic concurrency
control with an external lock provider, and its file-group model means two
writers touching different file groups do not conflict at all.
Notice what those three have in common: the strength of the commit protocol is a property of the catalog or the storage layer, not of the format. An Iceberg table on a REST catalog and the same table on a filesystem catalog have different correctness guarantees under concurrent writes, and look identical on disk. Choose the catalog with the same care as the format, and treat “which component provides the atomic compare-and-swap” as a question you can answer for your stack.
What do you have to operate?
None of the three are maintenance-free, and underestimating this is the most common reason a lakehouse project gets into trouble six months in.
| Job | Iceberg | Delta | Hudi |
|---|---|---|---|
| Compact small files | CALL prod.system.rewrite_data_files |
OPTIMIZE |
compaction, inline or async |
| Expire old versions | CALL prod.system.expire_snapshots |
VACUUM |
Cleaner, hoodie.cleaner.* |
| Remove unreferenced files | CALL prod.system.remove_orphan_files |
VACUUM |
Cleaner |
| Shrink metadata | CALL prod.system.rewrite_manifests |
Checkpoints, automatic | Timeline archival to history/ |
| Re-sort for locality | rewrite_data_files with a sort order |
OPTIMIZE ... ZORDER BY, liquid clustering |
clustering action |
The cost of skipping each is specific. Never compacting gives you a table of tiny files where planning dominates query time. Never expiring gives you storage that grows without bound and, for Iceberg and Delta, a metadata layer that grows with it. Never compacting a Hudi merge-on-read table means readers merge an ever-growing stack of log files on every query.
A useful way to hold the difference: Iceberg and Delta ask you to schedule maintenance as separate jobs, while Hudi can run compaction and cleaning inline with writes. Inline is easier to get right and makes writes slower; scheduled is faster on the write path and easier to forget.
Which one should I pick?
Reduce it to properties of your workload rather than feature counts.
| If your workload is | Reach for | Because |
|---|---|---|
| CDC or streaming upserts keyed by a primary key, high frequency | Hudi | The record index turns an upsert into a point lookup rather than a join against the table |
| Large batch appends and snapshot-style rewrites | Iceberg or Delta | Neither pays for index maintenance you would not use |
| Partitioning you expect to get wrong and want to change later | Iceberg | Partition evolution and hidden partitioning are structural, not bolted on |
| Layout you want managed for you, or a Databricks-centred platform | Delta | Liquid clustering removes most partition-design decisions, and the format and the Spark engine are developed together |
| Many query engines, strong catalog story | Iceberg | The broadest engine support and a REST catalog spec others implement |
| Incremental consumption as a first-class pattern | Hudi | Incremental queries were the original design goal, not an added feature |
| Undecided, or different teams want different things | Any, plus XTable | Metadata conversion means the writer’s choice stops dictating the reader’s |
There is a row missing from that table, and it is worth saying out loud: plain partitioned Parquet is still a good answer for some tables. A table format is a commit protocol plus a metadata layer, and both cost something. If your data is append-only, read by one engine, small enough that listing is not a problem, and never needs a row corrected, Parquet with a Hive Metastore is less machinery and will not surprise you. The formats start paying for themselves when you need atomic commits across many files, row-level mutation, time travel, or planning that does not scale with partition count.
Equally, adopting two of them in one platform wants a reason. The maintenance jobs, retention semantics and failure modes all differ, and running two sets of them doubles the operational surface for no analytical gain.
Can I avoid choosing?
Increasingly, yes. Apache XTable (incubating) reads one format’s metadata into a format-agnostic model and writes the other formats’ metadata beside it, over the same Parquet files. The data is never copied or rewritten.
That changes the decision usefully. A Hudi ingestion pipeline can keep the index-backed upserts it needs while a Trino-based analytics team reads the same tables as Iceberg, and neither side has to move. The cost is a metadata sync job with its own schedule, plus one constraint worth knowing up front: incremental sync only works while the source format still retains history back to the last sync, which makes your cleaner and snapshot-expiry settings an input to whether the sync stays cheap.
XTable is the right tool when several readers genuinely need different metadata over one copy of the data. It is the wrong tool for avoiding a decision you are able to make.
How do I know it is working?
Most of what these formats do wrong announces itself. These are the signals worth being able to read:
| Symptom | Likely cause |
|---|---|
Cannot commit: stale table metadata or a retry storm on Iceberg |
Two writers contending. Check the catalog supports atomic swaps |
| Query planning slower than query execution | Small files, or metadata never compacted |
| Storage growing far faster than data | Snapshots or log versions never expired |
| A Hudi merge-on-read table getting steadily slower to read | Compaction not running, so log files accumulate per file slice |
| Delta reader errors mentioning a required table feature | The table uses a feature your reader version does not implement |
| A deleted row still visible via time travel | Working as designed. Retention has not expired that version yet |
| Duplicate keys after a partition value changed, in Hudi | A non-global index, where the key moved partitions |
The one to read carefully is the commit conflict, because it looks like a problem and usually is not. A writer that fails with a conflict after doing all its work is the system behaving correctly. The genuine pathology is the opposite: two writers that both succeed and one silently loses rows, which is what happens when the atomicity assumption underneath the commit protocol does not hold.
Frequently asked questions
Is one of these faster than the others? Not in a way that survives contact with your workload. The structural differences decide cost: an index lookup beats a join when the batch is small relative to the table, manifest pruning beats log replay at very high commit counts. Benchmark on your own data and hardware, because published numbers rarely share your shape.
Do I need a catalog, or can I point at a path?
Iceberg genuinely needs one, because nothing inside the table says which
metadata.json is current. Delta and Hudi can be read from a path, but you still
want a catalog for discovery, and Delta 4.x is moving commit responsibility
toward catalogs with catalogManaged.
Can I convert an existing Parquet dataset without rewriting it?
Yes, in all three, and it is worth knowing before planning a migration. Iceberg
has add_files, Delta has CONVERT TO DELTA, and Hudi has a BOOTSTRAP
operation that generates metadata pointing at the files already there.
Which one handles schema evolution best? They are closer than most comparisons suggest. All three track columns by id, so add, drop, rename and reorder work without rewriting data. The real gap is partition evolution, where Iceberg is alone in letting old data keep its original spec.
If I pick wrong, how expensive is it to change? Much less than it used to be. XTable converts metadata between all three over one copy of the data, so the escape hatch is a sync job rather than a full rewrite. That is a good reason to decide and move, not a reason to never decide.
Does merge-on-read mean I do not need compaction? It means the opposite. Merge-on-read defers the work, and compaction is where that work happens. All three want the job scheduled and watched.
Conclusion
Back to the checklist that started this. It is accurate and it is useless, because it describes the part where these three formats agree. Ask instead what each one writes down to answer “which files are live”, and the differences stop being a list of features and become one structural choice you can reason about: an immutable tree of snapshots behind an atomic pointer, an ordered log replayed from a checkpoint, or a timeline of instants over indexed file groups.
The single most predictive question is whether your writes are keyed. If records arrive as a stream of changes to known primary keys, Hudi’s index is doing work the other two would ask a join to do, and the difference grows as the table grows relative to the batch. If records arrive as large batches that replace or append to partitions, the index is overhead and Iceberg or Delta will be simpler to run. Partition evolution and hidden partitioning are the strongest reasons to prefer Iceberg specifically, because they are the two things genuinely hard to retrofit later.
What has changed recently is that this stopped being a one-way door. Metadata conversion means a table written by one format can be read as another over the same files, so choosing imperfectly costs less than it did when these comparisons started being written. Pick the format that matches how your data arrives, operate its maintenance jobs properly, and treat interoperability as the escape hatch it is rather than as a reason to defer the decision indefinitely.
References
- Apache Iceberg table specification for snapshots, manifests, delete files and format versions
- Delta Lake protocol specification at v4.4.0, the authoritative description of
_delta_log, checkpoints and table features - Apache Hudi overview and table types for the timeline, file groups and Copy-on-Write versus Merge-on-Read
TableProperties.java, where the copy-on-write defaults for delete, update and merge are set- Apache XTable for converting metadata between all three over one copy of the data
Found this useful?
These posts and tools are free. If one saved you an afternoon, you can buy me a coffee.