Row-level changes in Iceberg are written one of two ways. Copy-on-write (CoW) rewrites every data file that contains a changed row. Merge-on-read (MoR) writes small delete files and leaves the data files alone, so readers have to apply the deletes at scan time. The wrong mode for a workload shows up as either slow, expensive MERGE jobs (CoW on a table with frequent small updates) or queries that get slower every day (MoR without compaction).
This cheat sheet covers the properties, the checks and the maintenance for both modes.
The write mode properties
Each operation has its own property. Setting one does not change the others.
| Property | Controls | Values | Default |
|---|---|---|---|
write.delete.mode | DELETE FROM | copy-on-write, merge-on-read | copy-on-write |
write.update.mode | UPDATE | copy-on-write, merge-on-read | copy-on-write |
write.merge.mode | MERGE INTO | copy-on-write, merge-on-read | copy-on-write |
write.delete.isolation-level (and .update., .merge.) | Conflict checks for concurrent writers | serializable, snapshot | serializable |
format-version | Table spec | 1, 2, 3 | Varies by Iceberg version; check SHOW TBLPROPERTIES |
Merge-on-read needs format version 2 or later. Version 1 tables support only copy-on-write.
-- Check what a table is actually using
SHOW TBLPROPERTIES prod.sales.orders;
-- Switch all three row-level operations to merge-on-read
ALTER TABLE prod.sales.orders SET TBLPROPERTIES (
'format-version' = '2',
'write.delete.mode' = 'merge-on-read',
'write.update.mode' = 'merge-on-read',
'write.merge.mode' = 'merge-on-read'
);
Warning
Upgrading
format-versionis one-way. Every engine that reads the table (older Spark, Trino, Snowflake, Athena, Flink jobs) must support the new version before you change it. Test with each reader on a copy of the table first.
A common mistake is setting only write.delete.mode. MERGE still defaults to copy-on-write, so the nightly MERGE keeps rewriting whole files and nobody can explain why the change did nothing.
Which mode for which workload
| Workload | Better fit | Why |
|---|---|---|
| Batch MERGE touching a large share of each file | CoW | Rewriting is cheap compared with the reads you save |
| Frequent small upserts (CDC every few minutes) | MoR | Writes stay small; CoW would rewrite large files for a few rows |
| GDPR-style deletes of a few rows across many partitions | MoR, then compact | CoW rewrites a file per touched partition |
| Read-heavy BI tables with rare changes | CoW | Readers never pay the delete-apply cost |
| Streaming upserts from Flink | MoR (equality deletes) | Flink upsert mode writes equality deletes; compaction is required |
Delete file types
| Type | Written by | Read cost |
|---|---|---|
| Position delete (v2) | Spark MoR DELETE/UPDATE/MERGE | Reader joins file path + row position; cheap per file but grows with count |
| Equality delete (v2) | Flink upsert, some CDC writers | Reader must compare key values against every older data file in scope; expensive |
| Deletion vector (v3) | Recent Spark/Iceberg on v3 tables | At most one per data file, stored in Puffin files; replaces position deletes |
Equality deletes are the ones that hurt most. A table receiving Flink upserts for a week without compaction can scan slower by an order of magnitude.
Measure delete-file build-up
The files metadata table includes a content column: 0 = data, 1 = position deletes, 2 = equality deletes.
SELECT content,
count(*) AS files,
sum(record_count) AS records,
round(sum(file_size_in_bytes)/1e9, 2) AS gb
FROM prod.sales.orders.files
GROUP BY content
ORDER BY content;
Per partition, to find where compaction is most needed:
SELECT partition,
sum(CASE WHEN content = 0 THEN 1 ELSE 0 END) AS data_files,
sum(CASE WHEN content > 0 THEN 1 ELSE 0 END) AS delete_files
FROM prod.sales.orders.files
GROUP BY partition
ORDER BY delete_files DESC
LIMIT 20;
The snapshot summary shows what each commit added:
SELECT committed_at, operation,
summary['added-delete-files'] AS added_delete_files,
summary['total-delete-files'] AS total_delete_files,
summary['total-position-deletes'] AS total_pos_deletes,
summary['total-equality-deletes'] AS total_eq_deletes
FROM prod.sales.orders.snapshots
ORDER BY committed_at DESC
LIMIT 20;
Summary keys vary slightly by writer and version; if a key is null, list the whole summary map for one snapshot and use the names you see.
Compaction for merge-on-read tables
By default rewrite_data_files does not rewrite a data file just because deletes point at it. Lower delete-file-threshold so files with deletes get rewritten and the deletes applied.
-- Rewrite data files that have 2+ delete files referencing them, last 7 days only
CALL prod.system.rewrite_data_files(
table => 'sales.orders',
where => 'order_date >= current_date() - 7',
options => map(
'delete-file-threshold', '2',
'target-file-size-bytes', '536870912',
'partial-progress.enabled', 'true',
'max-concurrent-file-group-rewrites', '10'
)
);
Compact the position delete files themselves when you cannot afford a full data rewrite yet. This also drops deletes that point at files already removed:
CALL prod.system.rewrite_position_delete_files(table => 'sales.orders');
Then expire old snapshots so the replaced files can be cleaned up:
CALL prod.system.expire_snapshots(
table => 'sales.orders',
older_than => TIMESTAMP '2026-11-06 00:00:00',
retain_last => 50
);
Caution
expire_snapshotsremoves the ability to time-travel to the expired snapshots and permanently deletes files only they referenced. Checkolder_thanagainst your recovery requirements and any readers pinned to an old snapshot before running it.
Tip
partial-progress.enabledcommits file groups as they finish. On a large table a failure halfway no longer throws away hours of work, and the commit conflicts with concurrent writers are smaller.
Concurrency trade-offs
- CoW MERGE under
serializableisolation conflicts with any concurrent commit that added files matching its filter. Long CoW MERGE jobs on hot partitions retry and fail more often. - MoR writes are smaller and commit faster, which reduces the conflict window, but compaction itself becomes a writer that can conflict with them. Schedule compaction on partitions that are no longer receiving writes, using
where. snapshotisolation allows more concurrency but can let two writers both apply changes based on the same old state. Use it only when you understand what the writers do.
Quick symptom map
| Symptom | Likely cause | Fix |
|---|---|---|
| MERGE time grows with table size, not batch size | CoW rewriting large files for few rows | write.merge.mode = merge-on-read plus scheduled compaction |
Changed write.delete.mode, MERGE still slow | Only DELETE switched | Set write.merge.mode too |
| Reads slower every day after switching to MoR | Delete files never compacted | rewrite_data_files with delete-file-threshold |
| Flink upsert table scans very slowly | Equality deletes accumulating | Frequent compaction of recent partitions |
| Another engine fails reading the table | Format version or deletion vectors unsupported | Check reader support before upgrading format-version |
| Compaction fails with commit conflicts | Rewriting partitions still being written | Restrict with where; enable partial progress |
Prevent it next time
- Pick the write mode per table when you create it, and set all three properties explicitly so nobody relies on defaults.
- Put the delete-file query above on a dashboard and alert when the delete-to-data file ratio for any partition crosses a threshold you choose.
- Schedule
rewrite_data_fileswithdelete-file-thresholdfor every MoR table, followed byexpire_snapshotsandremove_orphan_fileson their own cadence. - Record each reader engine and its Iceberg support level before any
format-versionchange.