When to use this
Use it when an Iceberg table goes from one writer to many: a streaming append, a nightly MERGE, GDPR deletes and a compaction job, all against the same table. The usual failures are:
CommitFailedExceptionafter retries are exhausted.ValidationException: Found conflicting files that can contain records matching ...from aMERGEorDELETE.- Planning time that grows week by week.
- Many small files after a "successful" migration.
Work through each section before go-live. Every item is something that has caused a production incident.
1. Catalog: the commit is only as safe as the catalog
An Iceberg commit is an atomic swap of the table's current metadata pointer. The catalog provides that atomicity.
- The catalog is
hive,rest,glue,jdbcornessie, nothadoop, for any table with more than one writer. The Hadoop catalog relies on atomic rename, which object stores such as S3 don't provide. - Every engine (Spark, Trino, Flink, Snowflake or Databricks external access) points at the same catalog for writes. Two catalogs tracking the same table location is silent data loss: each one overwrites the other's commits.
- Hive Metastore catalogs: the metastore database and HMS service are sized for lock traffic (commits take an HMS lock), and HMS is highly available.
- REST catalogs: you know the server's own commit retry and rate limits.
spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions
spark.sql.catalog.lake=org.apache.iceberg.spark.SparkCatalog
spark.sql.catalog.lake.type=rest
spark.sql.catalog.lake.uri=https://catalog.internal/api/catalog
spark.sql.catalog.lake.warehouse=prod
2. Commit retries match the concurrency
When two writers commit at once, one wins. The loser refreshes metadata, re-validates and retries. Appends almost always succeed on retry. Overwrites and row-level operations may fail validation instead (see section 3).
| Property | Default | Notes |
|---|---|---|
commit.retry.num-retries | 4 | Raise for tables with frequent concurrent appends |
commit.retry.min-wait-ms | 100 | Backoff floor |
commit.retry.max-wait-ms | 60000 | Backoff ceiling |
commit.retry.total-timeout-ms | 1800000 | Total time spent retrying |
ALTER TABLE lake.sales.orders SET TBLPROPERTIES (
'commit.retry.num-retries' = '10',
'commit.retry.min-wait-ms' = '500'
);
- Retries are raised on hot tables. A retry is cheap for appends: only metadata is rewritten, not data.
- Streaming jobs commit no more often than needed (trigger interval of a minute or more, not seconds). Every commit is a new snapshot and a new metadata file, and it competes with every other writer.
3. Isolation levels and conflict detection
Row-level operations validate that no conflicting change landed since they read the table:
| Property | Default | Values |
|---|---|---|
write.delete.isolation-level | serializable | serializable, snapshot |
write.update.isolation-level | serializable | serializable, snapshot |
write.merge.isolation-level | serializable | serializable, snapshot |
serializable fails a MERGE if another writer added files that could contain matching rows. snapshot only fails on conflicting deletes. It allows more concurrency, but a MERGE can then miss rows appended concurrently.
- The
MERGEcondition includes the partition column, so validation is scoped to the partitions it touches. AMERGEthat matches onorder_idalone conflicts with every append to the table:
MERGE INTO lake.sales.orders t
USING updates s
ON t.order_date = s.order_date AND t.order_id = s.order_id
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;
- Writers that touch disjoint partitions are scheduled so they actually stay disjoint. A late-data append to yesterday's partition conflicts with a
MERGEon yesterday. - Jobs that hit
ValidationExceptionretry the whole job (re-read and recompute). Iceberg can't retry a failed validation for you. -
snapshotisolation is a documented decision for that table, not a quick fix for a conflict.
4. Copy-on-write or merge-on-read
| Property | Default | Values |
|---|---|---|
write.delete.mode | copy-on-write | copy-on-write, merge-on-read |
write.update.mode | copy-on-write | same |
write.merge.mode | copy-on-write | same |
- Frequent small
MERGE/DELETEon large partitions usemerge-on-read. Copy-on-write rewrites whole data files for a handful of changed rows, so commits are long and conflict windows are wide. - Every engine that reads the table supports v2 delete files. Old readers ignore them or fail, which shows deleted rows.
- Merge-on-read tables have scheduled delete-file compaction, or read performance degrades:
CALL lake.system.rewrite_position_delete_files(table => 'sales.orders');
CALL lake.system.rewrite_data_files(
table => 'sales.orders',
options => map('delete-file-threshold', '5'));
5. Write distribution and file size
-
write.distribution-modeis set explicitly on partitioned tables (hashfor most,rangewhen there is a sort order). Spark's default depends on the Iceberg and Spark versions. Without clustering by partition, each task writes a file into every partition it sees, which is the small-files explosion.
ALTER TABLE lake.sales.orders SET TBLPROPERTIES (
'write.distribution-mode' = 'hash',
'write.target-file-size-bytes' = '536870912'
);
- Streaming appends, which can't be large, have scheduled
rewrite_data_fileswith awhereclause limited to recent partitions.
6. Compaction doesn't fight the writers
Compaction is a writer too. A big rewrite_data_files that loses a commit race throws away hours of work.
- Compaction uses partial progress, so it commits in groups instead of one big commit at the end:
CALL lake.system.rewrite_data_files(
table => 'sales.orders',
where => 'order_date >= current_date() - INTERVAL 7 DAYS',
options => map(
'partial-progress.enabled', 'true',
'partial-progress.max-commits', '10',
'max-concurrent-file-group-rewrites', '8'));
- Compaction of partitions that a
MERGEis rewriting is scheduled outside thatMERGE's window. - Only one compaction job runs per table at a time (enforce it in the orchestrator).
7. Metadata growth
Each commit writes a new metadata.json. Each snapshot keeps manifests alive. With high-frequency commits, planning slows and the metadata directory grows.
- Old metadata files are cleaned up on commit:
ALTER TABLE lake.sales.orders SET TBLPROPERTIES (
'write.metadata.delete-after-commit.enabled' = 'true',
'write.metadata.previous-versions-max' = '50'
);
- Snapshot expiry runs on a schedule (
history.expire.max-snapshot-age-msdefaults to 5 days, but nothing expires until you run the procedure):
CALL lake.system.expire_snapshots(
table => 'sales.orders',
older_than => TIMESTAMP '2026-11-02 00:00:00',
retain_last => 50);
CALL lake.system.rewrite_manifests('sales.orders');
- Health is monitored from metadata tables:
SELECT count(*) AS snapshots FROM lake.sales.orders.snapshots;
SELECT count(*) AS manifests, sum(added_data_files_count) FROM lake.sales.orders.manifests;
SELECT committed_at, operation, summary['added-data-files'] AS added
FROM lake.sales.orders.snapshots ORDER BY committed_at DESC LIMIT 20;
Caution
remove_orphan_fileswith a shortolder_thandeletes files that an in-flight writer has written but not yet committed. That writer's commit then references missing files, and the table is corrupt. Keepolder_thanat least as long as your longest-running write job (days, not minutes).
8. Audit before publish (optional)
For tables that downstream teams must never see half-written, use write-audit-publish on a branch:
ALTER TABLE lake.sales.orders SET TBLPROPERTIES ('write.wap.enabled' = 'true');
ALTER TABLE lake.sales.orders CREATE BRANCH audit_20261109;
spark.conf.set("spark.wap.branch", "audit_20261109")
# run the load, then data quality checks against the branch
spark.conf.unset("spark.wap.branch")
spark.sql("CALL lake.system.fast_forward('sales.orders', 'main', 'audit_20261109')")
- Publish (
fast_forward) is a single small commit, so it rarely conflicts.
9. Observability and ownership
- Commit failures and retry counts from writer logs are collected and alerted on (search for
CommitFailedExceptionandValidationException). - Each table has one owner who approves changes to properties, partition spec and maintenance schedules.
- Snapshot summaries carry the job name, so you can tell from
.snapshotswhich writer did what:
(df.writeTo("lake.sales.orders")
.option("snapshot-property.job", "orders_nightly_merge")
.append())
Prevent it next time
Put these table properties in the table DDL template, not in the tribal knowledge of whoever created the first table. Review new multi-writer tables against this checklist before the second writer is switched on.