Free Interactive Course · Apache Iceberg

MERGE, UPDATE & DELETE

Data lakes used to be append-only — changing a single row meant rewriting a whole partition by hand. Iceberg gives you real row-level SQL: UPDATE and DELETE by predicate, and MERGE INTO for upserts and CDC. Here's each command, plus the one tuning decision that governs how they perform — copy-on-write vs merge-on-read.

The tables

Same db.orders table. In this module we correct, remove, and upsert individual rows — the live cells work on a scratch copy (orders_wip) so the shared orders seed stays intact for other lessons.

customerscustomer_id, name, country, tier, signup_date
ordersorder_id, customer_id, product, category, amount, qty, status, order_date
3.0

Row-level changes need format-version 2

UPDATE, DELETE, and MERGE all change existing rows. To do that efficiently, Iceberg needs format-version 2 (or later), which adds delete files — small side files that mark rows as removed without rewriting every data file. Version 1 tables are append-only.

Spark SQL
-- opt a table into row-level operations
ALTER TABLE db.orders SET TBLPROPERTIES ('format-version' = '2');

-- (new tables: set it at CREATE time, as in Module 1)
-- CREATE TABLE db.orders (...) USING iceberg TBLPROPERTIES ('format-version'='2');
You also need the IcebergSparkSessionExtensions from setup — without it, Spark's parser rejects MERGE INTO and row-level UPDATE/DELETE on a data-lake table entirely.
3.1

UPDATE — correct rows in place

UPDATE … SET … WHERE changes matching rows and commits a new snapshot. Below we re-label every cancelled order as refunded. The runnable cell does it on a scratch copy so it's safe to re-run:

Syntax
UPDATE db.table SET col = expr [, ...] WHERE <condition>;
Spark SQL
UPDATE db.orders
SET status = 'refunded'
WHERE status = 'cancelled';

SELECT order_id, product, status FROM db.orders
WHERE order_id IN (4, 5, 6) ORDER BY order_id;
Run it live in the browser — edit the SQL and press Run
SQL
⌘/Ctrl + Enter
Iceberg UPDATE is a real transactional operation, not a full-table rewrite — with merge-on-read (below) it writes tiny delete files plus the new versions of the changed rows. Keep the WHERE as selective as you can; UPDATE with no predicate touches every row.
3.2

DELETE — remove rows by predicate

DELETE FROM … WHERE removes matching rows. GDPR erasure, dropping test data, purging a bad batch — all one statement, committed atomically as a new snapshot (and recoverable by time travel until you expire it).

Syntax
DELETE FROM db.table WHERE <condition>;
Spark SQL
DELETE FROM db.orders WHERE status = 'cancelled';

SELECT count(*) AS remaining FROM db.orders;
Run it live in the browser — edit the SQL and press Run
SQL
⌘/Ctrl + Enter
A predicate that lines up with the partitioning (e.g. deleting a whole order_date day on a day-partitioned table) is dramatically cheaper — Iceberg can drop entire data files instead of writing per-row delete files. More on that in Partitioning.
3.3

MERGE INTO — the upsert

MERGE INTO is the feature people come to Iceberg for. In one atomic statement it compares a target table to a source, then updates rows that match, inserts rows that don't, and optionally deletes matched rows. It's the backbone of CDC pipelines, slowly-changing dimensions, and idempotent upserts.

Syntax
MERGE INTO db.target t
USING source s  ON t.key = s.key
WHEN MATCHED [AND cond] THEN UPDATE SET col = s.col, ...
WHEN MATCHED [AND cond] THEN DELETE
WHEN NOT MATCHED [AND cond] THEN INSERT (cols) VALUES (s.cols);

Say a CDC batch order_updates arrives: order 4 shipped, order 6 delivered, and a brand-new order 7. One MERGE applies all three changes — updating the two existing rows and inserting the new one:

Spark SQL
MERGE INTO db.orders t
USING order_updates s
  ON t.order_id = s.order_id
WHEN MATCHED THEN UPDATE SET t.status = s.status
WHEN NOT MATCHED THEN INSERT *;
After MERGE — orders 4 & 6 updated in place, order 7 inserted; all other rows untouched
order_idproductstatusaction
4Desk LampshippedUPDATED
6USB-C CabledeliveredUPDATED
7WebcampendingINSERTED
baked output — Iceberg-only surface (snapshot / metadata state), not runnable in the browser engine
Add WHEN MATCHED AND s.op = 'delete' THEN DELETE to handle CDC tombstones in the same statement, or a guard like WHEN MATCHED AND t.status <> s.status THEN UPDATE … to skip no-op rewrites. One rule: if two source rows match the same target key, MERGE errors — dedupe the source first (a row_number() window keeping the latest per key).
3.4

Copy-on-write vs merge-on-read

This is the single most important tuning decision for row-level changes. When a row changes, Iceberg can either rewrite whole data files immediately, or write small delete files and reconcile at read time. You choose per operation via table properties.

Spark SQL
-- copy-on-write (default for v2 writes): fast reads, heavier writes
ALTER TABLE db.orders SET TBLPROPERTIES (
  'write.update.mode'  = 'copy-on-write',
  'write.delete.mode'  = 'copy-on-write',
  'write.merge.mode'   = 'copy-on-write');

-- merge-on-read: fast writes, extra work at read time
ALTER TABLE db.orders SET TBLPROPERTIES (
  'write.update.mode'  = 'merge-on-read',
  'write.delete.mode'  = 'merge-on-read',
  'write.merge.mode'   = 'merge-on-read');
Spark SQL
-- how each mode behaves
COW trades write speed for read speed; MOR does the reverse
Copy-on-write (COW)Merge-on-read (MOR)
On writeRewrites entire data files containing changed rowsWrites small delete files + new rows only
Write costHigher (more I/O per change)Lower (fast, small commits)
Read costLower (no reconciliation)Higher (merge deletes at read time)
Best forRead-heavy tables, infrequent updatesWrite-heavy / streaming / frequent CDC
NeedsRegular compaction to stay fast
baked output — Iceberg-only surface (snapshot / metadata state), not runnable in the browser engine
Rule of thumb: start with copy-on-write (the default) for batch tables read often and updated rarely. Switch to merge-on-read for streaming/CDC tables taking many small changes — then schedule compaction (rewrite_data_files) to fold the delete files back in, or reads slowly degrade.
3.5

Mission: which rows need the nightly MERGE?

D
DevData Engineer · Lakehouse teamnow
Before we point the nightly CDC MERGE at db.orders, I want to eyeball its targets. From orders, give me order_id and status for every order that isn't in a final state yet — anything pending or shipped — ordered by order_id. Those are the rows a status update would touch. 🌙
🎯 Your mission From orders, return order_id and status where status is 'pending' or 'shipped', ordered by order_id.
SQL
⌘/Ctrl + Enter

Frequently asked questions

How do I do an upsert in Apache Iceberg?
Use MERGE INTO: match a target table against a source on a key, then WHEN MATCHED THEN UPDATE SET … to update existing rows and WHEN NOT MATCHED THEN INSERT * to insert new ones, all in one atomic statement. This is the standard upsert and the basis of CDC pipelines. Make sure the table is format-version 2 and that no two source rows match the same target key (dedupe the source first).
Can you UPDATE and DELETE rows in Iceberg?
Yes. On a format-version 2 table, UPDATE db.table SET col = expr WHERE … changes matching rows and DELETE FROM db.table WHERE … removes them, each committed atomically as a new snapshot. These are real row-level operations, not full-table rewrites, and are recoverable by time travel until the snapshot is expired. You also need the Iceberg Spark SQL extensions enabled.
What is copy-on-write vs merge-on-read in Iceberg?
They are two strategies for applying row-level changes. Copy-on-write rewrites entire data files that contain changed rows at write time — slower writes but fast reads. Merge-on-read writes small delete files plus new rows and reconciles them at read time — fast writes but slower reads that need regular compaction. Choose per operation with write.update.mode, write.delete.mode and write.merge.mode. Use COW for read-heavy tables, MOR for write-heavy or streaming/CDC tables.
Why does my Iceberg MERGE fail with a cardinality error?
MERGE INTO errors when more than one source row matches the same target key, because it can't decide which change to apply. Dedupe the source before merging — typically with a row_number() window partitioned by the key, ordered by an update timestamp, keeping only the latest row per key. Then run the MERGE against the deduplicated source.
Do Iceberg UPDATE, DELETE and MERGE need format-version 2?
Efficient row-level operations rely on delete files, which are a format-version 2 feature; version 1 tables are append-only. Set 'format-version'='2' in TBLPROPERTIES at CREATE time or with ALTER TABLE, and enable the IcebergSparkSessionExtensions so Spark's parser accepts MERGE INTO and row-level UPDATE/DELETE.

Ready for the full data stack?

Iceberg is the table format at the heart of the lakehouse. When you want the pipelines, Spark, Kafka, and orchestration around it — plus a full end-to-end project — the Data Engineering course is free too.

Explore Data Engineering →

Comments

0

Join the conversation. Sign in to leave a comment — questions and feedback welcome.