"Format Wars, Ceasefire" — Delta, Hudi, Paimon, DuckLake and Interoperability
Rohan builds a feature matrix to pick a table format and every row says yes. Ingrid puts one gold table in Iceberg, Delta Lake and DuckLake and runs one late basket through each: six files, four files, or two inlined rows and a snapshot record in PostgreSQL. Delta logs and checkpoints, Hudi and Paimon for upserts, DuckLake's SQL catalog and data inlining, UniForm and XTable, and why the catalog decides more than the format.
Skim Card
One thing to remember: the table formats have converged on features; choose by who writes, who reads and which catalog decides the current version.
- Iceberg, Delta, Hudi, Paimon and DuckLake all version a set of immutable files; they differ in where that list lives and who commits.
- A feature matrix says yes everywhere; the real limits are which engines can write a table and which catalog arbitrates commits.
- One late basket: Iceberg wrote six objects, Delta with deletion vectors four, DuckLake none: two inlined rows plus a snapshot record in PostgreSQL.
- UniForm needs deletion vectors off: 20 one-row updates rewrote 9.35 MB of Parquet, against 31 KB with deletion vectors.
- Interop layers translate metadata for readers: DuckDB read the Delta table as Iceberg through UniForm, which kept no Iceberg history here.
- DuckLake keeps small writes in its catalog until flushed; back up PostgreSQL with the bucket.
- Delta on S3 has one safe writer unless every writer uses the DynamoDB log store; catalog-managed tables aim to fix that (check status).
Use … When … Delta, with Iceberg reads through Unity Catalog Databricks writes the tables; other engines only read Iceberg with a REST catalog several engines must write, or you want an exit option Paimon (Flink-first) or Hudi (Spark-centred) streaming upserts by key are the main workload DuckLake a small team, one DuckDB-centred node, many small writes Verified against: Delta Lake 4.4.1 on Spark 4.1.3, DuckDB 2.0.0 with DuckLake 1.1, Apache Iceberg 1.12.0, Apache Polaris 1.8.0, PostgreSQL 18.6; Hudi 1.2.1, Paimon 2.1.0, XTable 0.4.0-incubating (docs) · Snowflake and Databricks docs · 28 October 2026
Jump to: Concepts · Hands-On · Landscape · Design Drill
Story Opening
The supplier team’s contractor had delivered the fill-rate tables as Delta Lake, written from a Databricks trial. Store operations ran its own marts in DuckLake on one server. Rohan spent an evening on a feature matrix: ACID commits, time travel, schema evolution, deletion vectors, row-level updates, Iceberg, Delta, Hudi, Paimon, DuckLake.
“Every row says yes,” Rohan said the next morning. “I’ll pick the one with the most ticks and convert the others nightly.”
“Every row says yes because they’ve converged,” Ingrid said. “Your matrix skips the questions that decide it: who writes the table, who reads it, and who decides which version is current. And a nightly copy is a second truth. Let’s put one table in three formats and send one late basket through each.”
The Quick Map
| Backend instinct | Data engineering reality | Why it matters |
|---|---|---|
| Pick the format with the most features | The major formats share the core features; their engines and catalogs differ | The wrong choice shows up as an engine that can’t write your table |
| Metadata is an implementation detail | Metadata is where formats differ: a tree of files, a log in a folder, or rows in a database | It sets the cost of a small write and what a reader must replay |
| Keep two formats in sync with a nightly copy | Interop layers translate metadata over the same files, read-only | A copy drifts; a translation lags and can’t be written |
One UPDATE costs one row | One row cost about 1.6 KB of data or 467 KB here, depending on table settings | Write amplification is a table property, not a query property |
Concepts, with use cases
What every table format agrees on
A table format is a contract over immutable data files: a metadata record of which files make up version N, and an atomic way to publish version N+1 (Part 7 opened Iceberg’s). Iceberg, Delta Lake, Apache Hudi, Apache Paimon and DuckLake all do this, and all offer ACID commits, time travel, schema evolution and row-level changes. They differ in three ways that a feature matrix hides:
- Where the metadata lives: a tree of JSON and Avro files (Iceberg), a log of JSON commits with Parquet checkpoints (Delta), a timeline plus a metadata table (Hudi), LSM-organised files (Paimon), or tables in a SQL database (DuckLake).
- Who arbitrates a commit: a catalog’s compare-and-swap (Iceberg), “create the next log file only if absent” on storage or a catalog (Delta), a database transaction (DuckLake).
- Which engines can read and, separately, write it.
+ checkpoint every 10"] --> D2["Parquet + deletion vectors"] end subgraph DuckLake P3["PostgreSQL: ducklake_* tables
(snapshots, files, inlined rows)"] --> D3["Parquet"] end
What breaks if you choose by features. The table works for the team that chose it and nobody else: one engine writes it, three others read a stale export. Larkspur: the gold tables are read by DuckDB, Trino and Spark, and written by Spark and PyIceberg, so “who can write it” rules out more than any feature does. Elsewhere: a media company whose data science runs on Databricks and whose finance team runs on Snowflake has a catalog question, not a format question.
Delta Lake: a log in a folder
A Delta Lake table is a folder of Parquet files plus _delta_log/: numbered JSON commits, each listing files added and removed with their statistics, and a Parquet checkpoint that folds the log into one file so readers don’t replay every commit (the lab’s tables wrote one every 10 commits). A commit succeeds if the writer creates the next numbered file first. As of 4.4.1 (October 2026), the core comes with:
- Deletion vectors: a small file marking deleted rows, so an
UPDATEwrites the changed row and a bitmap instead of rewriting the file. The Delta docs page still says “This feature is in experimental support mode”. - Catalog-managed tables: “the catalog coordinates commit atomicity” (Unity Catalog is the example), and path-based access stops working. The docs say it is “available in Delta Lake 4.0.1 and above”, the 4.1.0 release notes said “still in preview”, and later notes state no status: check status before relying on it.
- Delta Kernel: Java and Rust libraries that let connectors read and write Delta without reimplementing the protocol.
What breaks without it. Without deletion vectors, every small update or delete rewrites whole files (step 4: 9.35 MB for 20 rows); without the log, there is no atomic commit at all. Larkspur: the contractor’s fill-rate tables. Elsewhere: an adtech firm on Databricks keeps impressions in Delta and lets deletion vectors absorb thousands of small consent deletions a day.
Hudi and Paimon: formats built around upserts
Apache Hudi (1.2.1) organises a table around record keys. Its record index, kept in Hudi’s internal metadata table, maps each key to the file holding it, so an upsert touches only the files it must. Tables are copy-on-write (“optimized for read-heavy workloads”) or merge-on-read (updates go to log files merged at query time and compacted later), and incremental queries “only return new data written to the table since an instant on the timeline”. Larkspur: the orders table replicated from PostgreSQL by CDC (Part 16 builds it on Iceberg) is keyed upserts all day. Elsewhere: a freight company upserts shipment status by tracking number and feeds downstream jobs only the changes.
Apache Paimon (2.1.0) is streaming-first: primary-key tables use “LSM-powered streaming updates with multiple merge engines and changelog producers”; its docs cover Flink first, then Spark and a Python SDK. A write lands in a small sorted run that compaction merges later, like your database’s LSM storage. Larkspur: Season 3’s live stock level per store and SKU, upserted every few seconds. Elsewhere: an IoT platform keeps the latest reading per device and emits a changelog of what changed.
What breaks without them. A format built for batch rewrites handles a stream of single-key upserts with many small files and expensive merges. Both are kept out of the hands-on here (memory); “Choosing” below says when they win.
DuckLake: the catalog is a database
DuckLake 1.1 (spec and extension released 21 October 2026, shipped with DuckDB 2.0) keeps all metadata (snapshots, schemas, file lists, statistics) in tables of a SQL database (PostgreSQL, SQLite or a DuckDB file) and only Parquet in object storage. A commit is one database transaction. Its signature feature is data inlining: an insert or delete of up to 10 rows (the default limit) is stored as rows in the catalog database, not as a file, until ducklake_flush_inlined_data writes it out.
DuckDB is the reference client; other clients (DataFusion, Spark, community Trino connectors, pg_ducklake) exist “at various levels of maturity”. On 26 August 2026 DuckLabs, the company behind DuckDB, announced that it is joining AWS; the projects “will remain the intellectual property of the DuckDB Foundation and open source under the MIT license”.
What breaks without it. File-based metadata makes every tiny write cost several objects and a catalog round trip. Larkspur: store operations’ marts take dozens of single-row corrections a day on one server. Elsewhere: a SaaS startup streams small per-tenant updates into analytics tables without a Spark cluster.
Interoperability: translation, not conversion
Two layers let one copy of the data look like another format:
- Delta UniForm writes Iceberg metadata beside the Delta log after each commit (“asynchronously after a Delta Lake write transaction completes”). Open-source requirements: column mapping, reader 2 / writer 7, a Hive Metastore as catalog, and no deletion vectors (“UniForm does not work on tables with deletion vectors enabled”). It is “read-only from an Iceberg and Hudi perspective”, and only one conversion runs at a time, so one Iceberg version can cover several Delta commits that arrive faster (here, each of 21 versions got its own). In Delta 4.4.1 the module “supports Spark 4.1 and is not compatible with Spark 4.2”. On Databricks the same idea is “Iceberg reads”, read-only, served through Unity Catalog’s Iceberg REST endpoint; there, Iceberg v3 reads support deletion vectors.
- Apache XTable (0.4.0-incubating) translates metadata between Hudi, Delta and Iceberg (Paimon and Parquet as sources only). The project lists its own limits: no Hudi or Iceberg merge-on-read tables, no Delta deletion vectors, and commit timestamps that are not synchronised.
Both translate metadata only; neither lets a second engine write. The vendor-neutral path is to agree on a catalog protocol instead: Iceberg’s REST catalog (Part 9) is what Snowflake, Trino, Spark, DuckDB and Unity Catalog all speak.
What breaks without it. Nightly copies double storage and give two answers between runs. Larkspur: the contractor’s Delta tables become readable as Iceberg by DuckDB without a copy (step 6). Elsewhere: a retailer’s Databricks team writes Delta while its Snowflake team reads the same files through Iceberg metadata.
Choosing
| Scenario | Choose | Why | Caveat |
|---|---|---|---|
| Databricks-centred | Delta, with Iceberg reads for other engines | Databricks defaults managed tables to Delta and builds its runtime features on it | Open-source UniForm works on Spark 4.1 only and drops deletion vectors with Iceberg v2 readers |
| Several engines write | Iceberg with a REST catalog | Spark, Flink, Trino, DuckDB and Snowflake read and write it through one catalog protocol | Features arrive engine by engine (Part 7’s v3 matrix) |
| Streaming keyed upserts | Paimon if the pipeline is Flink-first; Hudi if it is Spark-centred | LSM merges or a record index make single-key upserts cheap | A third format for other engines to support; Larkspur’s CDC (Part 16) still lands in Iceberg because every reader already speaks it, and pays with MERGE and compaction |
| Small team, one node | DuckLake | One database transaction per commit, data inlining for small writes | Non-DuckDB clients exist “at various levels of maturity” |
Under the Hood
Three ways to commit
- Iceberg asks the catalog to swap a pointer if it hasn’t moved (Part 7). Commits scale with the catalog; the metadata JSON carries the snapshot list, so it grows: 1,718 bytes at load, 25,312 after 20 more commits (step 4).
- Delta commits by creating
N.jsononly if it doesn’t exist yet. S3 has had conditional writes since 2024 (Part 3), but Delta’s S3 log store doesn’t use them: its docs still say “S3 currently does not provide mutual exclusion”, and the request to use them was closed as not planned. So it is safe for one writer only (see Gotchas). Catalog-managed tables (check status) are designed to move the arbitration into a catalog that implements their commit protocol. Readers start at the last checkpoint and replay the JSON after it. - DuckLake commits with an SQL transaction: insert the new snapshot, file and statistics rows, and let the database’s own concurrency control order writers. Reading the current table is a query, and paths are stored relative to
DATA_PATH(path_is_relative = tinducklake_data_file).
Where a small write goes
A one-row UPDATE is a delete plus an insert. Iceberg merge-on-read writes a one-row data file, a position-delete file, then a new metadata tree. Delta with deletion vectors writes a one-row file and a bitmap for the old file. Without deletion vectors, Delta copies every other row of the file. DuckLake inlines both halves as catalog rows. Step 3 measures each.
Hands-On
The code is in the every-record-counts folder of the companion-workspaces repository (tag every-record-counts/part-8). Spark runs Delta Lake 4.4.1 (delta-spark_4.1_2.13, delta-iceberg_2.13) in Part 7’s Spark 4.1.3 image, writing to SeaweedFS through Hadoop’s S3A; DuckDB 2.0 on the host writes Iceberg through Polaris and DuckLake with PostgreSQL as its catalog. Trino isn’t needed; the README’s matrix has the memory figures (3.2 GiB peak).
make up-spark # core + Spark (Delta jars are in the image)uv run python scripts/duck.py --local sql/part08/stage.sql # daily store sales as Parquetuv run python -m scripts.part08_formats reset # drop part08 tables, files, DuckLake cataloguv run python -m scripts.part08_formats loadStep 1: one table, four homes
The table is a daily store-sales gold table that DuckDB recomputes from Part 7’s staged POS lines (uploads up to 30 September, one row per basket line): 73,246 store-days from 1 June to 30 September, plus 46 store-days whose local business date is already 1 October. sum(sales_minor) is 322,704,113,319; it adds paise, pence and Singapore cents, so treat it as a checksum, not money. DuckDB creates the Iceberg and DuckLake copies (CREATE TABLE … AS FROM read_parquet(…)); DuckLake attaches with sql/part08/ducklake_attach.sql:
ATTACH 'ducklake:postgres:host=localhost port=5432 dbname=ducklake user=larkspur password=larkspur' AS dl ( DATA_PATH 's3://larkspur-lake/ducklake/');Spark creates two Delta tables (sql/part08/spark/01_delta.sql, trimmed):
CREATE TABLE part08.delta_dv (...) USING deltaLOCATION 's3a://larkspur-lake/delta/part08/delta_dv'TBLPROPERTIES ('delta.enableDeletionVectors' = 'true');
CREATE TABLE part08.delta_uniform (...) USING deltaLOCATION 's3a://larkspur-lake/delta/part08/delta_uniform'TBLPROPERTIES ('delta.enableIcebergCompatV2' = 'true', 'delta.universalFormat.enabledFormats' = 'iceberg');Its SHOW TBLPROPERTIES confirms UniForm’s prerequisites were switched on by Delta itself: delta.columnMapping.mode = name, minReaderVersion 2, minWriterVersion 7. The session config (spark/delta.conf.template) adds the Delta extension and catalog, Spark’s embedded Hive metastore (UniForm needs one) and S3A credentials for SeaweedFS.
Step 2: where the metadata lives
uv run python -m scripts.part08_formats layout format object kind objects bytes--------------- ---------------------------- ------- ------- Iceberg Iceberg manifest (Avro) 1 3,457 Iceberg Iceberg manifest list (Avro) 1 1,538 Iceberg Iceberg metadata (JSON) 1 1,718 Iceberg data (Parquet) 1 333,491 Delta (DVs) Delta checksum (.crc) 2 5,568 Delta (DVs) Delta commit (JSON) 2 2,174 Delta (DVs) data (Parquet) 1 466,089Delta (UniForm) Delta checksum (.crc) 2 7,597Delta (UniForm) Delta commit (JSON) 2 3,762Delta (UniForm) Iceberg manifest (Avro) 1 6,948Delta (UniForm) Iceberg manifest list (Avro) 1 4,458Delta (UniForm) Iceberg metadata (JSON) 1 2,147Delta (UniForm) data (Parquet) 1 467,513 DuckLake data (Parquet) 1 464,920
DuckLake catalog (PostgreSQL database `ducklake`): 30 tables, 12 with rows(The 12 tables with rows are trimmed: columns, one data file, its statistics, two snapshots.) Iceberg keeps a tree of files; Delta keeps a log (two commits: create, insert); the UniForm table keeps both; DuckLake’s bucket holds data only, and its metadata is rows in PostgreSQL. The Iceberg data file is smaller because Iceberg’s default codec, write.parquet.compression-codec = zstd, applies; the other writers used Snappy.
Step 3: one late basket
late applies sql/part08/late_basket.sql to each table, one UPDATE per store-day (DuckDB for Iceberg and DuckLake, spark/jobs/part08_late_baskets.py for Delta; Spark’s timing lines are trimmed below):
UPDATE {table}SET baskets = baskets + 1, sales_minor = sales_minor + 1999WHERE store_id = '{store_id}' AND business_date = DATE '2026-09-30'uv run python -m scripts.part08_formats lateOne late basket (GB-0001, 30 September), one UPDATE per table; objects written: format object kind objects bytes--------------- ---------------------------- ------- ------- Iceberg Iceberg manifest (Avro) 2 6,814 Iceberg Iceberg manifest list (Avro) 1 1,626 Iceberg Iceberg metadata (JSON) 1 2,895 Iceberg data (Parquet) 1 813 Iceberg delete file (Parquet) 1 865 Delta (DVs) Delta checksum (.crc) 1 3,781 Delta (DVs) Delta commit (JSON) 1 2,205 Delta (DVs) data (Parquet) 1 1,538 Delta (DVs) deletion vector (.bin) 1 43Delta (UniForm) Delta checksum (.crc) 1 4,334Delta (UniForm) Delta commit (JSON) 1 2,004Delta (UniForm) Iceberg manifest (Avro) 1 6,948Delta (UniForm) Iceberg manifest list (Avro) 1 4,456Delta (UniForm) Iceberg metadata (JSON) 1 2,147Delta (UniForm) data (Parquet) 1 467,512DuckLake catalog rows added: ducklake_inlined_data_1_1 +1, ducklake_inlined_delete_1 +1, ducklake_snapshot +1, ducklake_snapshot_changes +1Six objects for Iceberg, four for Delta with deletion vectors (its commit JSON records numCopiedRows 0 and numDeletionVectorsAdded 1), none for DuckLake: two inlined rows plus a snapshot record. The UniForm table rewrote the whole 467 KB file (numCopiedRows 73,245), then wrote Iceberg metadata for the new version.
Step 4: twenty late baskets
The same command carries on with GB-0002 to GB-0020, one commit each:
All 20 late baskets (one UPDATE commit each), per table: format objects data files data bytes other objects--------------- ------- ---------- ---------- ------------- Iceberg 120 20 16,260 100 Delta (DVs) 83 20 30,755 63Delta (UniForm) 123 20 9,350,266 103 DuckLake 0 0 0 0DuckLake catalog rows: 29 -> 109Iceberg metadata file: 1,718 bytes at load, 25,312 bytes after the 20th commitDelta checkpoints: 00000000000000000010.checkpoint.parquet, 00000000000000000020.checkpoint.parquetTwenty one-row changes cost Iceberg 120 objects and the UniForm table 9.35 MB of rewritten data; DuckLake absorbed them as 80 catalog rows. Merge-on-read Iceberg and Delta with deletion vectors both owe compaction now (Part 10).
Step 5: flush the inlined rows
uv run python -m scripts.part08_formats flushrows flushed: 20; DuckLake objects: 1 -> 3inlined rows left: ducklake_inlined_data_1_1 0, ducklake_inlined_delete_1 0CALL ducklake_flush_inlined_data('dl') wrote the 20 rows as one data file and the 20 deletes against the big file as one delete file.
Step 6: every reader, one answer
uv run python -m scripts.part08_formats interop reader (DuckDB 2.0) rows sales_minor----------------------------- ------ --------------- Iceberg via Polaris 73,246 322,704,153,299 DuckLake 73,246 322,704,153,299 Delta (DVs), delta_scan 73,246 322,704,153,299 Delta (UniForm), delta_scan 73,246 322,704,153,299Delta (UniForm), iceberg_scan 73,246 322,704,153,299
UniForm Iceberg metadata files: 21 (Delta versions 1..21); the newest: 00000-ba98bc92-8ddb-4cd4-abee-572620d16592.metadata.json┌─────────────────────┬───────────┬─────────┐│ snapshot_id │ operation │ records │├─────────────────────┼───────────┼─────────┤│ 5501564805624811233 │ append │ 73246 │└─────────────────────┴───────────┴─────────┘
DuckDB UPDATE on the Delta table: BinderException: Binder Error: Can only update base table(Type row of DuckDB’s table trimmed; sales_minor is the same mixed-currency checksum as in step 1.) All five readers agree: the staged checksum plus 20 × 1,999. DuckDB read the Delta table as Iceberg from UniForm’s metadata file, with no copy. Two limits showed up as well:
- Every UniForm metadata file is named
00000-…and holds one snapshot: in this lab, each conversion started a fresh Iceberg table, so Iceberg readers see the current version but no Iceberg history. The docs require a Hive Metastore and the lab has only Spark’s embedded one; whether a full metastore keeps the history wasn’t tested. - DuckDB reads Delta (deletion vectors included) but its
deltaextension doesn’t doUPDATE,DELETEorMERGE: an engine limit, separate from any format’s.
uv run pytest -q tests/test_part08.py re-runs the sequence and checks each claim above: 8 passed in 118 s (with Spark up; make ci ran 67 passed, 8 Trino tests skipped).
The Landscape
Product names and status as of 28 October 2026.
| Need | Open source | AWS | GCP | Azure | Snowflake / Databricks | Notes |
|---|---|---|---|---|---|---|
| A native table format | Iceberg 1.12, Delta 4.4, Hudi 1.2, Paimon 2.1, DuckLake 1.1 | S3 Tables (Iceberg); Athena reads Delta (no DML) and Hudi | BigQuery Apache Iceberg managed tables | Fabric OneLake stores Delta | Snowflake Iceberg tables; Databricks Delta by default, managed Iceberg tables | Part 1 compares the platforms |
| Read one format as another | Delta UniForm (Spark 4.1 only), Apache XTable (incubating) | not verified (2026-10-28) | not verified (2026-10-28) | OneLake Iceberg metadata virtualization (available) | Snowflake Delta Direct (Delta files in object storage); Databricks Iceberg reads on Delta tables | Read-only everywhere listed |
| One catalog every engine can use | Apache Polaris, Unity Catalog OSS, Lakekeeper, Apache Gravitino | Glue Iceberg REST endpoint; S3 Tables | Lakehouse runtime catalog | not verified (2026-10-28) | Horizon Catalog; Unity Catalog Iceberg REST (managed Iceberg read/write, Delta read-only) | Part 9 |
Tips & Gotchas
Gotcha — Delta on S3 has one safe writer by default. The Delta docs: “Concurrent writes to the same Delta table on S3 storage from multiple Spark drivers can lead to data loss.” Multi-cluster writes need
S3DynamoDBLogStore(experimental) in every writer. Catalog-managed tables (check status) are designed to let the catalog arbitrate commits instead, but only with a catalog that implements their protocol. The lab’s Delta tables have one writer.
Gotcha — inlined rows live only in the catalog. After step 4, DuckLake’s bucket had no trace of 20 changes. A bucket copy is not a backup, and a tool reading the Parquet under
DATA_PATHdirectly misses them: back up the PostgreSQL database with the files, and flush before handing files to anything that isn’t a DuckLake client.
Gotcha — a table feature raises the bar for every reader. Turning on deletion vectors moved the table to reader version 3; a client without that feature can’t read it at all. List the engines that read a table before enabling deletion vectors, column mapping or Iceberg v3.
Tip — test interop with totals, not row counts. Step 6 compares a row count and a money total from every reader. Run the same per-partition check from each engine before telling a second team that a translated table is theirs to use.
Gotcha — translated history is not native history. Test the time travel your readers need through the translated metadata: here, UniForm’s Iceberg view kept one snapshot per conversion.
Troubleshooting
| Symptom | Likely cause | How to confirm | Fix |
|---|---|---|---|
DELTA_CREATE_TABLE_WITH_NON_EMPTY_LOCATION after deleting a table’s files | S3A wrote “directory” marker objects; SeaweedFS keeps them as directories after the files go | List the prefix: empty directories remain | Delete through SeaweedFS’s filer (reset does), or use a new location |
ClassNotFoundException: software.amazon.awssdk.http.apache.ApacheHttpClient when Spark opens s3a:// | hadoop-aws 3.4 needs AWS SDK v2 pieces beyond the S3 client | The stack trace names the class | Add the SDK’s apache-client and s3-transfer-manager at the SDK version already on the classpath (the Dockerfile does) |
DuckDB iceberg_scan: cannot join incompatible paths: "file:/opt/…" | The metadata was written with absolute file: paths inside a container; Iceberg stores absolute paths | iceberg_metadata(…) shows the file_path values | Write tables to object storage every reader can reach; allow_moved_paths didn’t remap them |
Design Drill
The question: “A mobile games publisher runs its data platform on Databricks: about 300 TB of player telemetry, matchmaking and live-ops tables in Delta, written by Lakeflow pipelines and Spark jobs. The finance analytics team is moving to Snowflake, the live-ops engineers want Trino for ad-hoc queries, and leadership wants the option to leave Databricks within two years. What do you choose for table format and catalog, and what will you test before committing?”
Clarify first
- Will Snowflake or Trino ever write these tables, or only read them?
- How fresh must external reads be: minutes, hours?
- Which Delta features do the big tables use today (deletion vectors, column mapping, liquid clustering)?
- Must Unity Catalog permissions apply to external readers, or is a separate grant model acceptable?
- Is “leave Databricks” about compute only, or the catalog too?
The shape of a strong answer
- Keep one writer per table and make the catalog the contract: Unity Catalog’s Iceberg REST endpoint serves both Snowflake (catalog integration, catalog-linked database) and Trino.
- Existing Delta tables that only Databricks writes: enable Iceberg reads, read-only for Iceberg clients. With Iceberg v2 reads, deletion vectors must go; Iceberg v3 reads keep them, but only if every reader supports v3 (Trino 484’s v3 is experimental).
- Tables that others must write, and all new shared tables: managed Iceberg tables in Unity Catalog, readable and writable from external Iceberg clients; this is also the exit path.
- No nightly exports: a copy is a second truth with its own lag and cost.
- Test freshness: commit a change and measure when Snowflake and Trino see it (metadata is generated after the Delta commit).
- Test correctness: per-partition row counts and money totals from all three engines, as in step 6.
- Test features: a table with deletion vectors, a renamed column, a dropped column; confirm each engine reads them, and check time travel through the translated metadata.
- Test governance: confirm Unity Catalog grants reach Snowflake and Trino through credential vending, and that no reader holds broader storage keys.
- Test the exit: point a second Iceberg REST catalog (Polaris, Glue) at the same files and read them.
- Monitor metadata generation; the trigger to resync is
MSCK REPAIR TABLE … SYNC METADATA.
Trade-offs to say out loud
- Iceberg reads on Delta keep Databricks’ Delta features; managed Iceberg is portable but may lag on Databricks-only features.
- Turning deletion vectors off for Iceberg v2 reads turns small updates into file rewrites (9.35 MB vs 31 KB for 20 rows in step 4).
- Snowflake’s Delta Direct reads files from object storage, which bypasses the catalog’s governance.
Common weak answers
- “XTable solves it”: incubating, translates metadata only, no Delta deletion vectors.
- Pointing Snowflake at Unity Catalog’s Delta tables as Delta: “Snowflake doesn’t support creating Iceberg tables from Delta table definitions in other catalogs”.
- Picking a format without asking who writes.
Further Reading
- Delta Lake, “Universal Format (UniForm)” (requirements and limits). https://docs.delta.io/latest/delta-uniform.html
- Delta Lake, “Storage configuration” (S3 single- and multi-cluster writes). https://docs.delta.io/latest/delta-storage.html
- DuckLake, “Data Inlining” and the 1.1 release post. https://ducklake.select/docs/stable/duckdb/advanced_features/data_inlining
- Apache Hudi, “Table & Query Types”. https://hudi.apache.org/docs/table_types
- Apache XTable, project site (scope and limitations). https://xtable.apache.org/
- Databricks, “Read Delta Lake tables with Iceberg clients”. https://docs.databricks.com/aws/en/delta/uniform
Story Closing
Rohan deleted the matrix. The contractor’s tables would stay Delta with Iceberg metadata for readers, the shared gold tables would stay Iceberg, and store operations could keep DuckLake as long as nobody outside DuckDB needed to write to it. Of the five promises, the part served consistency: five readers, three formats, one total.
“So the format matters less than the thing that says which files are the table,” Rohan said.
“The catalog,” Ingrid said. “Which is why it gets its own part.”
In Part 9, Rohan runs Polaris properly: catalogs, principals and roles, four engines on one table, and a role that can read gold but not raw.
This is Part 8 of “Every Record Counts: Data Engineering for Backend Engineers” — Season 1: Foundations & the Lakehouse.