Loading data into Iceberg: appends, overwrites and row-level SQL
Iceberg accepts data four ways and each leaves a different mark in the snapshot log. A MERGE applying a changelog produced inserts, updates and deletes in one statement and recorded it as a single overwrite snapshot.
- The four ways in
- Overwrite, and the one that surprises people
- Row-level SQL
- Batch loading patterns
- Controlling the files a write produces
- Idempotent batch loads
- Writing from outside Spark
- What to check after a load
- Writing well
- Common misconceptions
- Run it yourself
- A model worth keeping
- References
- Trademarks
TL;DR
- Four ways in:
append,overwritePartitions,overwritewith a filter, and row-level SQL (UPDATE,DELETE,MERGE INTO).- A single
MERGE INTOapplied inserts, updates and deletes together: a 1,000-row table plus a 1,000-row changelog produced init=500, upd=300, ins=500, 1,300 rows from one statement and one snapshot.- Each write records an
operationin the snapshot log:appendfor added files,overwritewhen files were replaced,deletewhen files were only removed or only delete files were added. A filtered overwrite with no rows, and aDELETEthat drops whole files, are bothdelete.- Write through the catalog:
writeTo(...), orsave()with the table’s name.save()with the table’s storage path failed outright on a Hive-catalog table.MERGEfails when two source rows match the same target row, but two source rows that match nothing are both inserted. Deduplicate before merging either way.- On a partitioned table the default write distribution is already
hash: 200 Spark partitions into 30 days wrote 30 files, against 5,999 withnone.
The four ways in
# 1. append: add data, touch nothing existing
df.writeTo("ice.blog_ingestion.events").append()
# 2. dynamic partition overwrite: replace only the partitions present in df
df.writeTo("ice.blog_ingestion.events").overwritePartitions()
# 3. filtered overwrite: replace exactly what the filter matches
df.writeTo("ice.blog_ingestion.events").overwrite(F.col("event_ts") >= F.lit("2026-01-07").cast("timestamp"))
# 4. row-level SQL
spark.sql("UPDATE ice.blog_ingestion.events SET region = 'r9' WHERE id < 100")
spark.sql("DELETE FROM ice.blog_ingestion.events WHERE region = 'test'")
spark.sql("MERGE INTO ice.blog_ingestion.events t USING changes s ON t.id = s.id ...")
These are fragments of the two complete scripts in Run it yourself,
which run every statement in this post against a partitioned events table
(days(event_ts)).
Write through the catalog. writeTo resolves the table through the catalog,
which is what makes the commit atomic and the table’s identity stable.
write.format("iceberg").save("ice.blog_ingestion.by_path") does the same, because
a name is resolved through the catalog too. Handing save() the table’s storage
location instead did not write anything on this Hive-catalog stack; it failed with:
[TABLE_OR_VIEW_NOT_FOUND] The table or view `s3a://warehouse/blog_ingestion.db`.by_path cannot be found.
Each statement records what it did. Measured on one table:
| Statement | Snapshot operation |
|---|---|
append() |
append |
overwritePartitions() |
overwrite |
overwrite(filter) with rows |
overwrite |
overwrite(filter) with an empty DataFrame |
delete |
DELETE of some rows in a file, copy-on-write |
overwrite |
DELETE that removes whole files |
delete |
DELETE under merge-on-read |
delete |
UPDATE under merge-on-read |
overwrite |
MERGE INTO under copy-on-write |
overwrite |
The rule behind it is in BaseRowDelta.operation(): data files added and nothing
removed is append; only delete files added, or files only removed, is delete;
anything else is overwrite.
That column is the audit trail. When a table changed unexpectedly, the sequence of operations says what kind of change happened before you go looking at data.
Overwrite, and the one that surprises people
overwritePartitions() is dynamic: it replaces the partitions present in the
incoming DataFrame and leaves every other partition alone. Reloading day 6
replaced day 6 only:
overwritePartitions() with day 6 only op=overwrite added=800 deleted=1000 delete_files=None rows=4800
rows per day: {'2026-01-05': 1000, '2026-01-06': 800, '2026-01-07': 1000, '2026-01-08': 1000, '2026-01-09': 1000}
The surprise is what happens when the DataFrame is empty. No partitions are present, so nothing is replaced, and nothing is committed either:
overwritePartitions() of an empty DataFrame: snapshots 3 -> 3 | rows 2400
The statement succeeds, no snapshot is written, and yesterday’s data stays. A pipeline whose upstream produced no rows is green and wrong.
overwrite(filter) is explicit: it replaces exactly what the filter matches,
whether or not the incoming data covers it. With data, the filter
event_ts >= 2026-01-07 replaced three days with one:
overwrite(event_ts >= 2026-01-07) with day 7 op=overwrite added=600 deleted=3000 delete_files=None rows=2400
With an empty DataFrame and a filter for day 6, it deleted day 6, and recorded a
delete:
overwrite(day 6) with an empty DataFrame op=delete added=None deleted=800 delete_files=None rows=1600
That is at least unambiguous. Use the filtered form when correctness depends on the old data being gone, and the dynamic form when you are topping up partitions you know you have.
Row-level SQL
The statements that make a lakehouse table behave like a database table.
UPDATE ice.blog_ingestion.events SET region = 'r9' WHERE id < 100;
DELETE FROM ice.blog_ingestion.events WHERE region = 'test';
Both work on format v1 by rewriting files, and on v2 either by rewriting
(copy-on-write, the default) or by writing delete files (merge-on-read), governed
by write.update.mode and write.delete.mode. On v1 both ran and both rewrote:
v1 UPDATE op=overwrite added=1000 deleted=1000 delete_files=None rows=1000
v1 DELETE op=overwrite added=775 deleted=1000 delete_files=None rows=775
Copy-on-write rewrites the whole file, not the changed rows. The UPDATE above
changed 100 rows and rewrote the 1,000-row file that held them. Merge-on-read
wrote one delete file instead:
DELETE rows inside day 8, merge-on-read op=delete added=None deleted=None delete_files=1 rows=2100
UPDATE rows inside day 8, merge-on-read op=overwrite added=250 deleted=None delete_files=1 rows=2100
The comparison of the two has both measured in more depth.
MERGE INTO
The statement that applies a changelog, doing all three operations in one pass:
MERGE INTO ice.blog_ingestion.tgt t USING changes s ON t.id = s.id
WHEN MATCHED AND s.chg = 'D' THEN DELETE
WHEN MATCHED THEN UPDATE SET t.v = s.v, t.op = 'upd'
WHEN NOT MATCHED THEN INSERT (id, v, op) VALUES (s.id, s.v, 'ins')
Against a 1,000-row table (ids 0 to 999) with a 1,000-row changelog (ids 500 to 1,499) overlapping half of it, where 200 of the matches are deletes:
op=init rows=500
op=ins rows=500
op=upd rows=300
total rows = 1300
snapshot ops: ['append', 'overwrite']
merge snapshot: added-records=800 deleted-records=500
Five hundred untouched rows, 500 inserted, 300 updated, 200 deleted, and one snapshot. The whole changelog applied atomically: no reader saw the inserts without the deletes. Under copy-on-write the snapshot summary shows the cost: the 500 matched rows were removed from their file and 800 rows (300 updated plus 500 new) written.
Two ways to get MERGE wrong
Duplicate keys in the source. If two rows of changes match the same
target row, the statement fails rather than picking one, because the outcome
would be arbitrary:
duplicate keys matching a target row: SparkRuntimeException: [MERGE_CARDINALITY_VIOLATION] The ON search condition of the MERGE statement matched...
The check only covers matched rows. Two source rows for a key that is not in
the target both reach WHEN NOT MATCHED, and both are inserted, without an error:
duplicate keys that match nothing: rows with id 5000 after MERGE = 2
So the failure protects updates and deletes, and the silent duplicate lands on inserts. Collapse to one row per key before every merge, using the source’s sequence number or commit timestamp:
w = Window.partitionBy("id").orderBy(F.col("log_seq").desc())
latest = changes.withColumn("rn", F.row_number().over(w)).filter("rn = 1").drop("rn")
after deduplicating by log_seq: id 100 has v = b
Ordering by arrival time instead of the source’s own sequence is a common bug: out-of-order delivery then applies an older version over a newer one, and the table is quietly wrong.
A match condition that is not the real key. Matching on a column that is not unique in the target updates every target row it matches. One source row keyed on an email that two customers share:
one source row, matched on a non-unique email: rows updated = 2
No error, because one source row matching many target rows is allowed. It usually passes testing, because the test data happens to be unique.
Batch loading patterns
Full reload: overwrite with a filter covering everything, or CREATE OR
REPLACE TABLE ... AS SELECT. Simple, and rewrites everything every time.
Incremental append: the cheapest path, and the one that accumulates small files fastest. Compaction is the other half of this pattern.
Partition reload: overwritePartitions() with the partitions you regenerated.
The normal shape for a daily pipeline that reprocesses late-arriving data.
Upsert: MERGE INTO, with the deduplication above.
Whichever you use, the file-count consequence is the same: every commit writes files, and nothing consolidates them until compaction runs. A pipeline appending hourly writes at least 24 files per partition per day.
Controlling the files a write produces
The most common ingestion problem is not correctness, it is file count, and it is decided by how the rows are distributed across write tasks.
write.distribution-mode tells Iceberg whether to shuffle before writing. When
the table does not set it, Spark picks a default from the table itself
(SparkWriteConf.defaultWriteDistributionMode): range if the table has a sort
order, hash if it is partitioned, none otherwise.
| Mode | Behaviour | Default when |
|---|---|---|
none |
write whatever partitioning the DataFrame has | the table is unpartitioned and unsorted |
hash |
shuffle by partition key before writing | the table is partitioned |
range |
range-partition, respecting sort order | the table has a sort order |
The default is what saves a partitioned table from the small-file explosion. The same 60,000 events over 30 days, spread across 200 Spark partitions, written with adaptive query execution off so the task count is visible:
input: 60,000 events over 30 days, 200 Spark partitions
write.distribution-mode (default) -> 30 files in 30 partitions
write.distribution-mode none -> 5999 files in 30 partitions
write.distribution-mode hash -> 30 files in 30 partitions
With none, every task writes a file for every day it holds rows for: close to
200 x 30. You only get that by setting none explicitly, so the thing to check is
a table where someone did. Set the size target at create time too:
ALTER TABLE ice.blog_ingestion.events SET TBLPROPERTIES (
'write.distribution-mode' = 'hash', -- shuffle by partition key
'write.target-file-size-bytes' = '268435456'); -- 256 MiB
If you repartition yourself instead, repartition by the partition value, not the raw column. Hashing the timestamp spreads each day over every task:
(df.repartition(24, F.to_date("event_ts")) # the days(event_ts) partition value
.sortWithinPartitions("region")
.writeTo("ice.blog_ingestion.events").append())
repartition(24, 'event_ts'), mode none -> 720 files
repartition(24, to_date(event_ts)), mode none -> 30 files
repartition(24, "event_ts") wrote 24 files for each of the 30 days; the same call
on to_date(event_ts) wrote one per day.
Check what a write actually produced rather than assuming:
SELECT partition, count(*) AS files,
round(avg(file_size_in_bytes) / 1048576, 3) AS avg_mb,
sum(record_count) AS rows
FROM ice.blog_ingestion.files_none.files
GROUP BY partition ORDER BY files DESC LIMIT 3;
+------------+-----+------+----+
|partition |files|avg_mb|rows|
+------------+-----+------+----+
|{2026-01-14}|200 |0.001 |2000|
|{2026-01-20}|200 |0.001 |2000|
|{2026-01-28}|200 |0.001 |2000|
+------------+-----+------+----+
Two hundred files for 2,000 rows. Average file sizes that small mean the next query against that partition pays for opening every one of them.
Idempotent batch loads
A reload that runs twice should not double the data, and append gives you no
help with that. Three patterns, in order of preference.
Partition overwrite, when the load is naturally partition-scoped:
(daily_df.writeTo("ice.blog_ingestion.events").overwritePartitions())
Re-running replaces the same partitions. Safe to repeat, with the empty-DataFrame caveat from earlier, which is worth guarding explicitly:
if daily_df.head(1):
daily_df.writeTo("ice.blog_ingestion.events").overwritePartitions()
else:
raise ValueError("refusing to run a load that produced no rows")
guard: refusing to run a load that produced no rows
Filtered overwrite, when correctness requires the old rows gone whether or not new ones arrived:
(daily_df.writeTo("ice.blog_ingestion.events")
.overwrite(F.to_date("event_ts") == F.lit("2026-01-06").cast("date")))
MERGE on a key, when the load overlaps arbitrary existing rows. Naturally idempotent because it reconciles rather than adds, once the source is deduplicated.
Writing from outside Spark
Two ingestion routes that are not a Spark DataFrame, and both come up.
Adopting files that already exist: data written by another tool, added to the table without copying:
CALL ice.system.add_files(
table => 'blog_ingestion.adopted',
source_table => '`parquet`.`s3a://warehouse/blog_ingestion_src/events_ok`');
This reads the Parquet footers for statistics and writes manifests pointing at the files where they are:
+-----------------+-----------------------+
|added_files_count|changed_partition_count|
+-----------------+-----------------------+
| 1| NULL|
+-----------------+-----------------------+
rows after add_files: 500 | data files point at: s3a://warehouse/blog_ingestion_src/...
Three things to know before you rely on it, each measured:
-
It does not check the schema. A file with
event_tsas a string and noamount_usdcolumn was added without an error, and after that every read of the table failed:add_files with a mismatched schema succeeded; rows = 600 reading the table afterwards: ClassCastException: class org.apache.iceberg.shaded.org.apache.arrow.vector.TimeStampMicroTZVector cannot b...Compare the file’s schema with the table’s before calling it.
-
It refuses the same files twice (
check_duplicate_filesdefaults to true):adding the same files twice: IllegalStateException: Cannot complete import because data files to be imported already exist within the ta... -
The table now owns those files.
DROP TABLE ... PURGEdeleted the adopted Parquet file from its original location, outside the table’s own folder:source Parquet files before DROP TABLE ... PURGE: 1 source Parquet files after DROP TABLE ... PURGE: 0If another system still reads those files, drop the table without
PURGE.
CREATE TABLE AS SELECT for the initial load of a derived table:
CREATE TABLE ice.blog_ingestion.events_summary
USING iceberg
PARTITIONED BY (days(event_ts))
TBLPROPERTIES ('write.target-file-size-bytes' = '268435456')
AS SELECT event_ts, region, count(*) AS n, sum(amount_usd) AS total
FROM ice.blog_ingestion.files_hash GROUP BY event_ts, region;
CTAS: rows = 60000 | files = 30 | target-file-size-bytes = 268435456
Set the table properties in the CREATE, because properties applied afterwards
only affect subsequent writes: the initial load will already have produced its
files.
What to check after a load
-- did the commit do what you expected?
SELECT operation, summary['added-data-files'] AS files,
summary['added-records'] AS records, summary['deleted-records'] AS deleted
FROM ice.blog_ingestion.events.snapshots ORDER BY committed_at;
The whole history of the table the first script builds:
+---------+-----+-------+-------+
|operation|files|records|deleted|
+---------+-----+-------+-------+
|append |5 |5000 |NULL |
|overwrite|1 |800 |1000 |
|overwrite|1 |600 |3000 |
|delete |NULL |NULL |800 |
|append |2 |2000 |NULL |
|overwrite|1 |1000 |1000 |
|overwrite|1 |750 |1000 |
|delete |NULL |NULL |1000 |
|delete |NULL |NULL |NULL |
|overwrite|1 |250 |NULL |
+---------+-----+-------+-------+
deleted-records on a load you believed was a partition top-up is the signal
that overwritePartitions or a filter replaced more than intended: the third row
shows a filter that removed 3,000 rows to add 600. It is the fastest way to catch
a scoping mistake, and it costs one query. A delete with deleted empty is a
merge-on-read delete: it wrote a delete file and removed no data file.
Writing well
Control the number of output files. Leave write.distribution-mode at its
default on partitioned tables, and if you repartition yourself, do it by the
partition value.
Set the target file size at create time rather than discovering it later, and know that it shapes compaction more reliably than ingestion.
Do not commit more often than you need to. Commit frequency drives metadata growth, three metadata files per commit, and is the main lever on long-term table health.
Common misconceptions
“overwritePartitions replaces the table.” It replaces only the partitions
present in the incoming data. With no rows, it replaces nothing and commits
nothing.
“MERGE is several statements.” It is one commit. The measured merge produced
one snapshot for inserts, updates and deletes together.
“MERGE always rejects duplicate keys.” Only duplicates that match a target
row. Unmatched duplicates are inserted as duplicates.
“save() is equivalent to writeTo.” With a table name it is; with a storage
path it did not write at all here.
“Appending is free.” Each append is a commit: three metadata files plus data files, and small files until compacted.
“UPDATE needs merge-on-read.” It works on any format version; the mode
decides whether files are rewritten or delete files written.
Run it yourself
Both scripts run on the local lakehouse stack described in Run every demo on this blog locally: Spark, a Hive Metastore and MinIO on one Docker network. Start it with:
git clone https://github.com/rangareddy/datalake-playground.git
cd datalake-playground
SPARK_VERSION=3.5.9 sh run_datalake.sh start
sh run_datalake.sh status
Every output in this post came from these scripts on Spark 3.5.9 with
Iceberg 1.11.0, whose runtime jar ships in the Spark image under
$ICEBERG_HOME. Snapshot ids aside, the numbers are deterministic.
Script 1: the write modes and their snapshot operations
Save as ingestion_modes.py:
"""Append, dynamic and filtered overwrite, and row-level SQL on an Iceberg events table.
Run inside the datalake-playground stack:
docker cp ingestion_modes.py spark-master:/tmp/
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit --master "local[2]" \
--driver-memory 1g --jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
/tmp/ingestion_modes.py'
"""
from datetime import datetime, timedelta
from pyspark.sql import SparkSession, functions as F
spark = (
SparkSession.builder.appName("iceberg-ingestion-modes")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.catalog.ice", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.ice.type", "hive")
.config("spark.sql.catalog.ice.uri", "thrift://hive-metastore:9083")
.config("spark.sql.catalog.ice.warehouse", "s3a://warehouse/")
.config("spark.eventLog.enabled", "false")
.config("spark.ui.enabled", "false")
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sql = spark.sql
T = "ice.blog_ingestion.events"
REGIONS = ["us-east", "us-west", "eu-west", "ap-south"]
def events(day, n=1000, id_start=0, region=None):
"""n events on one day, spread over the day, amounts deterministic."""
base = datetime(2026, 1, day)
rows = [(id_start + i,
base + timedelta(seconds=i * 86_400 // n),
region or REGIONS[i % 4],
round(10 + (i % 97) * 1.5, 2))
for i in range(n)]
return (spark.createDataFrame(rows, "id BIGINT, event_ts TIMESTAMP, region STRING, amount DOUBLE")
.withColumn("amount_usd", F.col("amount").cast("decimal(10,2)")).drop("amount"))
def last_snapshot(table=T):
r = sql(f"""SELECT snapshot_id, operation, summary['added-records'] AS added,
summary['deleted-records'] AS deleted, summary['added-delete-files'] AS del_files,
summary['total-records'] AS total
FROM {table}.snapshots ORDER BY committed_at DESC LIMIT 1""").collect()[0]
return r
def show(label, table=T):
r = last_snapshot(table)
rows = sql(f"SELECT count(*) FROM {table}").collect()[0][0]
print(f"{label:<44} op={r.operation:<9} added={r.added} deleted={r.deleted} "
f"delete_files={r.del_files} rows={rows}")
def snapshot_count(table=T):
return sql(f"SELECT count(*) FROM {table}.snapshots").collect()[0][0]
def per_day(table=T):
return {str(r.d): r.n for r in sql(
f"SELECT to_date(event_ts) AS d, count(*) AS n FROM {table} GROUP BY 1 ORDER BY 1").collect()}
sql("CREATE NAMESPACE IF NOT EXISTS ice.blog_ingestion")
sql(f"DROP TABLE IF EXISTS {T} PURGE")
sql(f"""CREATE TABLE {T} (id BIGINT, event_ts TIMESTAMP, region STRING, amount_usd DECIMAL(10,2))
USING iceberg PARTITIONED BY (days(event_ts))""")
print("== 1. the four ways in ==")
days = events(5, id_start=0)
for d in range(6, 10):
days = days.unionByName(events(d, id_start=(d - 5) * 1000))
days.writeTo(T).append()
show("append() of 5 days")
events(6, n=800, id_start=90_000).writeTo(T).overwritePartitions()
show("overwritePartitions() with day 6 only")
print(" rows per day:", per_day())
(events(7, n=600, id_start=91_000).writeTo(T)
.overwrite(F.col("event_ts") >= F.lit("2026-01-07").cast("timestamp")))
show("overwrite(event_ts >= 2026-01-07) with day 7")
print(" rows per day:", per_day())
print("\n== 2. the empty DataFrame ==")
empty = events(8, n=1).limit(0)
before = snapshot_count()
empty.writeTo(T).overwritePartitions()
print("overwritePartitions() of an empty DataFrame: snapshots", before, "->", snapshot_count(),
"| rows", sql(f"SELECT count(*) FROM {T}").collect()[0][0])
empty.writeTo(T).overwrite(F.to_date("event_ts") == F.lit("2026-01-06").cast("date"))
show("overwrite(day 6) with an empty DataFrame")
print(" rows per day:", per_day())
print("\n== 3. row-level SQL, copy-on-write (the default) ==")
events(8, id_start=8000).unionByName(events(9, id_start=9000)).writeTo(T).append()
sql(f"UPDATE {T} SET region = 'r9' WHERE id < 100")
show("UPDATE ... WHERE id < 100")
sql(f"DELETE FROM {T} WHERE region = 'us-west' AND to_date(event_ts) = DATE'2026-01-08'")
show("DELETE rows inside day 8")
sql(f"DELETE FROM {T} WHERE event_ts >= TIMESTAMP'2026-01-09 00:00:00'")
show("DELETE all of day 9 (whole files)")
print("\n== 4. row-level SQL, merge-on-read ==")
sql(f"""ALTER TABLE {T} SET TBLPROPERTIES ('write.delete.mode'='merge-on-read',
'write.update.mode'='merge-on-read', 'write.merge.mode'='merge-on-read')""")
sql(f"DELETE FROM {T} WHERE region = 'eu-west' AND to_date(event_ts) = DATE'2026-01-08'")
show("DELETE rows inside day 8, merge-on-read")
sql(f"UPDATE {T} SET region = 'r8' WHERE region = 'ap-south' AND to_date(event_ts) = DATE'2026-01-08'")
show("UPDATE rows inside day 8, merge-on-read")
print("\n== 5. format v1 ==")
V1 = "ice.blog_ingestion.events_v1"
sql(f"DROP TABLE IF EXISTS {V1} PURGE")
sql(f"""CREATE TABLE {V1} (id BIGINT, event_ts TIMESTAMP, region STRING, amount_usd DECIMAL(10,2))
USING iceberg PARTITIONED BY (days(event_ts)) TBLPROPERTIES ('format-version'='1')""")
events(5).writeTo(V1).append()
sql(f"UPDATE {V1} SET region = 'r9' WHERE id < 100")
show("v1 UPDATE", V1)
sql(f"DELETE FROM {V1} WHERE region = 'us-west'")
show("v1 DELETE", V1)
print("\n== 6. what to check after a load ==")
sql(f"""SELECT operation, summary['added-data-files'] AS files,
summary['added-records'] AS records, summary['deleted-records'] AS deleted
FROM {T}.snapshots ORDER BY committed_at""").show(truncate=False)
spark.stop()
Output:
== 1. the four ways in ==
append() of 5 days op=append added=5000 deleted=None delete_files=None rows=5000
overwritePartitions() with day 6 only op=overwrite added=800 deleted=1000 delete_files=None rows=4800
rows per day: {'2026-01-05': 1000, '2026-01-06': 800, '2026-01-07': 1000, '2026-01-08': 1000, '2026-01-09': 1000}
overwrite(event_ts >= 2026-01-07) with day 7 op=overwrite added=600 deleted=3000 delete_files=None rows=2400
rows per day: {'2026-01-05': 1000, '2026-01-06': 800, '2026-01-07': 600}
== 2. the empty DataFrame ==
overwritePartitions() of an empty DataFrame: snapshots 3 -> 3 | rows 2400
overwrite(day 6) with an empty DataFrame op=delete added=None deleted=800 delete_files=None rows=1600
rows per day: {'2026-01-05': 1000, '2026-01-07': 600}
== 3. row-level SQL, copy-on-write (the default) ==
UPDATE ... WHERE id < 100 op=overwrite added=1000 deleted=1000 delete_files=None rows=3600
DELETE rows inside day 8 op=overwrite added=750 deleted=1000 delete_files=None rows=3350
DELETE all of day 9 (whole files) op=delete added=None deleted=1000 delete_files=None rows=2350
== 4. row-level SQL, merge-on-read ==
DELETE rows inside day 8, merge-on-read op=delete added=None deleted=None delete_files=1 rows=2100
UPDATE rows inside day 8, merge-on-read op=overwrite added=250 deleted=None delete_files=1 rows=2100
== 5. format v1 ==
v1 UPDATE op=overwrite added=1000 deleted=1000 delete_files=None rows=1000
v1 DELETE op=overwrite added=775 deleted=1000 delete_files=None rows=775
== 6. what to check after a load ==
+---------+-----+-------+-------+
|operation|files|records|deleted|
+---------+-----+-------+-------+
|append |5 |5000 |NULL |
|overwrite|1 |800 |1000 |
|overwrite|1 |600 |3000 |
|delete |NULL |NULL |800 |
|append |2 |2000 |NULL |
|overwrite|1 |1000 |1000 |
|overwrite|1 |750 |1000 |
|delete |NULL |NULL |1000 |
|delete |NULL |NULL |NULL |
|overwrite|1 |250 |NULL |
+---------+-----+-------+-------+
Script 2: MERGE, duplicates, file counts, add_files and CTAS
Save as merge_and_files.py:
"""MERGE INTO, duplicate keys, output file counts, writing by path, add_files and CTAS.
Run inside the datalake-playground stack:
docker cp merge_and_files.py spark-master:/tmp/
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit --master "local[2]" \
--driver-memory 1g --jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
/tmp/merge_and_files.py'
"""
from datetime import datetime, timedelta
from pyspark.sql import SparkSession, Window, functions as F
spark = (
SparkSession.builder.appName("iceberg-merge-and-files")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.catalog.ice", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.ice.type", "hive")
.config("spark.sql.catalog.ice.uri", "thrift://hive-metastore:9083")
.config("spark.sql.catalog.ice.warehouse", "s3a://warehouse/")
.config("spark.eventLog.enabled", "false")
.config("spark.ui.enabled", "false")
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sql = spark.sql
NS = "ice.blog_ingestion"
SRC = "s3a://warehouse/blog_ingestion_src"
def err(e):
j = getattr(e, "java_exception", None)
if j is None:
msg = str(e).splitlines()[0]
return msg if len(msg) <= 110 else msg[:107] + "..."
while j.getCause() is not None: # a failed task wraps the real error
j = j.getCause()
msg = j.getClass().getSimpleName() + ": " + j.getMessage().splitlines()[0]
return msg if len(msg) <= 110 else msg[:107] + "..."
sql(f"CREATE NAMESPACE IF NOT EXISTS {NS}")
print("== 1. MERGE INTO applying a changelog ==")
TGT = f"{NS}.tgt"
sql(f"DROP TABLE IF EXISTS {TGT} PURGE")
sql(f"CREATE TABLE {TGT} (id BIGINT, v STRING, op STRING) USING iceberg")
spark.range(0, 1000).selectExpr("id", "concat('v', id) AS v", "'init' AS op").writeTo(TGT).append()
# 1,000 changes: ids 500..1499. Of the 500 that match, 200 are deletes and 300 updates.
changes = spark.range(500, 1500).selectExpr(
"id", "concat('new', id) AS v",
"CASE WHEN id < 700 THEN 'D' WHEN id < 1000 THEN 'U' ELSE 'I' END AS chg")
changes.createOrReplaceTempView("changes")
sql(f"""MERGE INTO {TGT} t USING changes s ON t.id = s.id
WHEN MATCHED AND s.chg = 'D' THEN DELETE
WHEN MATCHED THEN UPDATE SET t.v = s.v, t.op = 'upd'
WHEN NOT MATCHED THEN INSERT (id, v, op) VALUES (s.id, s.v, 'ins')""")
for r in sql(f"SELECT op, count(*) AS n FROM {TGT} GROUP BY op ORDER BY op").collect():
print(f"op={r.op:<5} rows={r.n}")
print("total rows =", sql(f"SELECT count(*) FROM {TGT}").collect()[0][0])
print("snapshot ops:", [r.operation for r in sql(f"SELECT operation FROM {TGT}.snapshots ORDER BY committed_at").collect()])
last = sql(f"""SELECT summary['added-records'] a, summary['deleted-records'] d
FROM {TGT}.snapshots ORDER BY committed_at DESC LIMIT 1""").collect()[0]
print(f"merge snapshot: added-records={last.a} deleted-records={last.d}")
print("\n== 2. duplicate keys in the source ==")
# id 100 is an untouched row still in the target
dup_match = spark.createDataFrame([(100, "a", "U", 1), (100, "b", "U", 2)], "id BIGINT, v STRING, chg STRING, log_seq INT")
dup_match.createOrReplaceTempView("dup_changes")
try:
sql(f"""MERGE INTO {TGT} t USING dup_changes s ON t.id = s.id
WHEN MATCHED THEN UPDATE SET t.v = s.v
WHEN NOT MATCHED THEN INSERT (id, v, op) VALUES (s.id, s.v, 'ins')""")
print("duplicate keys matching a target row: MERGE succeeded")
except Exception as e:
print("duplicate keys matching a target row:", err(e))
dup_new = spark.createDataFrame([(5000, "a", "I", 1), (5000, "b", "I", 2)], "id BIGINT, v STRING, chg STRING, log_seq INT")
dup_new.createOrReplaceTempView("dup_new")
sql(f"""MERGE INTO {TGT} t USING dup_new s ON t.id = s.id
WHEN MATCHED THEN UPDATE SET t.v = s.v
WHEN NOT MATCHED THEN INSERT (id, v, op) VALUES (s.id, s.v, 'ins')""")
print("duplicate keys that match nothing: rows with id 5000 after MERGE =",
sql(f"SELECT count(*) FROM {TGT} WHERE id = 5000").collect()[0][0])
w = Window.partitionBy("id").orderBy(F.col("log_seq").desc())
latest = dup_match.withColumn("rn", F.row_number().over(w)).filter("rn = 1").drop("rn")
latest.createOrReplaceTempView("dedup_changes")
sql(f"""MERGE INTO {TGT} t USING dedup_changes s ON t.id = s.id
WHEN MATCHED THEN UPDATE SET t.v = s.v""")
print("after deduplicating by log_seq: id 100 has v =",
sql(f"SELECT v FROM {TGT} WHERE id = 100").collect()[0][0])
print("\n== 3. a match condition that is not the key ==")
NK = f"{NS}.customers"
sql(f"DROP TABLE IF EXISTS {NK} PURGE")
sql(f"CREATE TABLE {NK} (id BIGINT, email STRING, tier STRING) USING iceberg")
spark.createDataFrame([(1, "a@x.com", "free"), (2, "a@x.com", "free"), (3, "b@x.com", "free")],
"id BIGINT, email STRING, tier STRING").writeTo(NK).append()
spark.createDataFrame([("a@x.com", "gold")], "email STRING, tier STRING").createOrReplaceTempView("tier_changes")
sql(f"MERGE INTO {NK} t USING tier_changes s ON t.email = s.email WHEN MATCHED THEN UPDATE SET t.tier = s.tier")
print("one source row, matched on a non-unique email: rows updated =",
sql(f"SELECT count(*) FROM {NK} WHERE tier = 'gold'").collect()[0][0])
print("\n== 4. how many files a write produces ==")
spark.conf.set("spark.sql.adaptive.enabled", "false") # show the raw task count
base = datetime(2026, 1, 1)
rows = spark.range(0, 60_000).selectExpr(
"id", f"timestamp'{base:%Y-%m-%d} 00:00:00' + make_interval(0, 0, 0, 0, 0, 0, id * 43.2) AS event_ts",
"element_at(array('us-east','us-west','eu-west','ap-south'), cast(id % 4 AS int) + 1) AS region",
"cast(10 + (id % 97) * 1.5 AS decimal(10,2)) AS amount_usd").repartition(200)
print("input: 60,000 events over", rows.select(F.to_date("event_ts")).distinct().count(), "days,",
rows.rdd.getNumPartitions(), "Spark partitions")
for mode in ["(default)", "none", "hash"]:
t = f"{NS}.files_{mode.strip('()')}"
sql(f"DROP TABLE IF EXISTS {t} PURGE")
props = "" if mode == "(default)" else f"TBLPROPERTIES ('write.distribution-mode'='{mode}')"
sql(f"""CREATE TABLE {t} (id BIGINT, event_ts TIMESTAMP, region STRING, amount_usd DECIMAL(10,2))
USING iceberg PARTITIONED BY (days(event_ts)) {props}""")
rows.writeTo(t).append()
files = sql(f"SELECT count(*) FROM {t}.files").collect()[0][0]
parts = sql(f"SELECT count(DISTINCT partition) FROM {t}.files").collect()[0][0]
print(f"write.distribution-mode {mode:<10} -> {files:>5} files in {parts} partitions")
RP = f"{NS}.files_repartition"
sql(f"DROP TABLE IF EXISTS {RP} PURGE")
sql(f"""CREATE TABLE {RP} (id BIGINT, event_ts TIMESTAMP, region STRING, amount_usd DECIMAL(10,2))
USING iceberg PARTITIONED BY (days(event_ts)) TBLPROPERTIES ('write.distribution-mode'='none')""")
(rows.repartition(24, "event_ts").sortWithinPartitions("region").writeTo(RP).append())
print("repartition(24, 'event_ts'), mode none ->",
sql(f"SELECT count(*) FROM {RP}.files").collect()[0][0], "files")
sql(f"DELETE FROM {RP}")
(rows.repartition(24, F.to_date("event_ts")).sortWithinPartitions("region").writeTo(RP).append())
print("repartition(24, to_date(event_ts)), mode none ->",
sql(f"SELECT count(*) FROM {RP}.files WHERE record_count > 0").collect()[0][0], "files")
spark.conf.set("spark.sql.adaptive.enabled", "true")
sql(f"""SELECT partition, count(*) AS files,
round(avg(file_size_in_bytes) / 1048576, 3) AS avg_mb,
sum(record_count) AS rows
FROM {NS}.files_none.files
GROUP BY partition ORDER BY files DESC LIMIT 3""").show(truncate=False)
print("== 5. writing by path instead of through the catalog ==")
BP = f"{NS}.by_path"
sql(f"DROP TABLE IF EXISTS {BP} PURGE")
sql(f"CREATE TABLE {BP} (id BIGINT, v STRING) USING iceberg")
spark.createDataFrame([(1, "a")], "id BIGINT, v STRING").writeTo(BP).append()
loc = sql(f"DESCRIBE TABLE EXTENDED {BP}").where("col_name = 'Location'").collect()[0][1]
try:
(spark.createDataFrame([(2, "b")], "id BIGINT, v STRING")
.write.format("iceberg").mode("append").save(loc))
print("save(path) succeeded")
except Exception as e:
print("save(path) on a Hive-catalog table:", err(e))
sql(f"REFRESH TABLE {BP}")
print("rows the catalog sees:", sql(f"SELECT count(*) FROM {BP}").collect()[0][0])
spark.createDataFrame([(3, "c")], "id BIGINT, v STRING").write.format("iceberg").mode("append").save(BP)
print("save('ice.blog_ingestion.by_path') by name: rows =", sql(f"SELECT count(*) FROM {BP}").collect()[0][0])
print("\n== 6. add_files: adopting Parquet files in place ==")
AF = f"{NS}.adopted"
sql(f"DROP TABLE IF EXISTS {AF} PURGE")
sql(f"CREATE TABLE {AF} (id BIGINT, event_ts TIMESTAMP, region STRING, amount_usd DECIMAL(10,2)) USING iceberg")
rows.limit(500).coalesce(2).write.mode("overwrite").parquet(f"{SRC}/events_ok")
sql(f"CALL ice.system.add_files(table => 'blog_ingestion.adopted', source_table => '`parquet`.`{SRC}/events_ok`')").show()
print("rows after add_files:", sql(f"SELECT count(*) FROM {AF}").collect()[0][0],
"| data files point at:", sql(f"SELECT file_path FROM {AF}.files LIMIT 1").collect()[0][0].split("/blog_ingestion_src/")[0] + "/blog_ingestion_src/...")
(rows.limit(100).selectExpr("id", "cast(event_ts AS string) AS event_ts", "region")
.coalesce(1).write.mode("overwrite").parquet(f"{SRC}/events_bad"))
try:
sql(f"CALL ice.system.add_files(table => 'blog_ingestion.adopted', source_table => '`parquet`.`{SRC}/events_bad`')").show()
print("add_files with a mismatched schema succeeded; rows =", sql(f"SELECT count(*) FROM {AF}").collect()[0][0])
try:
sql(f"SELECT count(*), min(event_ts) FROM {AF}").show()
except Exception as e:
print("reading the table afterwards:", err(e))
except Exception as e:
print("add_files with a mismatched schema:", err(e))
try:
sql(f"CALL ice.system.add_files(table => 'blog_ingestion.adopted', source_table => '`parquet`.`{SRC}/events_ok`')")
print("adding the same files twice succeeded")
except Exception as e:
print("adding the same files twice:", err(e))
def parquet_files(path):
jpath = spark._jvm.org.apache.hadoop.fs.Path(path)
fs = jpath.getFileSystem(spark._jsc.hadoopConfiguration())
if not fs.exists(jpath):
return 0
it = fs.listFiles(jpath, True)
n = 0
while it.hasNext():
n += it.next().getPath().getName().endswith(".parquet")
return n
print("source Parquet files before DROP TABLE ... PURGE:", parquet_files(f"{SRC}/events_ok"))
sql(f"DROP TABLE {AF} PURGE")
print("source Parquet files after DROP TABLE ... PURGE:", parquet_files(f"{SRC}/events_ok"))
print("\n== 7. CREATE TABLE AS SELECT with properties ==")
SUM = f"{NS}.events_summary"
sql(f"DROP TABLE IF EXISTS {SUM} PURGE")
sql(f"""CREATE TABLE {SUM}
USING iceberg
PARTITIONED BY (days(event_ts))
TBLPROPERTIES ('write.target-file-size-bytes' = '268435456')
AS SELECT event_ts, region, count(*) AS n, sum(amount_usd) AS total
FROM {NS}.files_hash GROUP BY event_ts, region""")
print("CTAS: rows =", sql(f"SELECT count(*) FROM {SUM}").collect()[0][0],
"| files =", sql(f"SELECT count(*) FROM {SUM}.files").collect()[0][0],
"| target-file-size-bytes =",
sql(f"SHOW TBLPROPERTIES {SUM} ('write.target-file-size-bytes')").collect()[0][1])
print("\n== 8. guarding against an empty load ==")
daily_df = rows.where("to_date(event_ts) = DATE'2026-02-15'")
try:
if daily_df.head(1):
daily_df.writeTo(f"{NS}.files_hash").overwritePartitions()
else:
raise ValueError("refusing to run a load that produced no rows")
except ValueError as e:
print("guard:", e)
spark.stop()
Output, with Spark’s own error logging removed:
== 1. MERGE INTO applying a changelog ==
op=init rows=500
op=ins rows=500
op=upd rows=300
total rows = 1300
snapshot ops: ['append', 'overwrite']
merge snapshot: added-records=800 deleted-records=500
== 2. duplicate keys in the source ==
duplicate keys matching a target row: SparkRuntimeException: [MERGE_CARDINALITY_VIOLATION] The ON search condition of the MERGE statement matched...
duplicate keys that match nothing: rows with id 5000 after MERGE = 2
after deduplicating by log_seq: id 100 has v = b
== 3. a match condition that is not the key ==
one source row, matched on a non-unique email: rows updated = 2
== 4. how many files a write produces ==
input: 60,000 events over 30 days, 200 Spark partitions
write.distribution-mode (default) -> 30 files in 30 partitions
write.distribution-mode none -> 5999 files in 30 partitions
write.distribution-mode hash -> 30 files in 30 partitions
repartition(24, 'event_ts'), mode none -> 720 files
repartition(24, to_date(event_ts)), mode none -> 30 files
+------------+-----+------+----+
|partition |files|avg_mb|rows|
+------------+-----+------+----+
|{2026-01-14}|200 |0.001 |2000|
|{2026-01-20}|200 |0.001 |2000|
|{2026-01-28}|200 |0.001 |2000|
+------------+-----+------+----+
== 5. writing by path instead of through the catalog ==
save(path) on a Hive-catalog table: [TABLE_OR_VIEW_NOT_FOUND] The table or view `s3a://warehouse/blog_ingestion.db`.by_path cannot be found. Ve...
rows the catalog sees: 1
save('ice.blog_ingestion.by_path') by name: rows = 2
== 6. add_files: adopting Parquet files in place ==
+-----------------+-----------------------+
|added_files_count|changed_partition_count|
+-----------------+-----------------------+
| 1| NULL|
+-----------------+-----------------------+
rows after add_files: 500 | data files point at: s3a://warehouse/blog_ingestion_src/...
+-----------------+-----------------------+
|added_files_count|changed_partition_count|
+-----------------+-----------------------+
| 1| NULL|
+-----------------+-----------------------+
add_files with a mismatched schema succeeded; rows = 600
reading the table afterwards: ClassCastException: class org.apache.iceberg.shaded.org.apache.arrow.vector.TimeStampMicroTZVector cannot b...
adding the same files twice: IllegalStateException: Cannot complete import because data files to be imported already exist within the ta...
source Parquet files before DROP TABLE ... PURGE: 1
source Parquet files after DROP TABLE ... PURGE: 0
== 7. CREATE TABLE AS SELECT with properties ==
CTAS: rows = 60000 | files = 30 | target-file-size-bytes = 268435456
== 8. guarding against an empty load ==
guard: refusing to run a load that produced no rows
Clean up
Drop the tables first, then remove what is left on storage. PURGE removes the
data and metadata files, but leaves empty folder markers behind:
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-sql --master "local[1]" \
--jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
--conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions \
--conf spark.sql.catalog.ice=org.apache.iceberg.spark.SparkCatalog \
--conf spark.sql.catalog.ice.type=hive \
--conf spark.sql.catalog.ice.uri=thrift://hive-metastore:9083 \
-e "DROP TABLE IF EXISTS ice.blog_ingestion.events PURGE;
DROP TABLE IF EXISTS ice.blog_ingestion.events_v1 PURGE;
DROP TABLE IF EXISTS ice.blog_ingestion.tgt PURGE;
DROP TABLE IF EXISTS ice.blog_ingestion.customers PURGE;
DROP TABLE IF EXISTS ice.blog_ingestion.files_default PURGE;
DROP TABLE IF EXISTS ice.blog_ingestion.files_none PURGE;
DROP TABLE IF EXISTS ice.blog_ingestion.files_hash PURGE;
DROP TABLE IF EXISTS ice.blog_ingestion.files_repartition PURGE;
DROP TABLE IF EXISTS ice.blog_ingestion.by_path PURGE;
DROP TABLE IF EXISTS ice.blog_ingestion.adopted PURGE;
DROP TABLE IF EXISTS ice.blog_ingestion.events_summary PURGE;
DROP NAMESPACE IF EXISTS ice.blog_ingestion"' 2>/dev/null
docker exec mc /usr/bin/mc rm --recursive --force minio/warehouse/blog_ingestion.db/
docker exec mc /usr/bin/mc rm --recursive --force minio/warehouse/blog_ingestion_src/
A model worth keeping
Every write is a commit, and the snapshot log records which kind. Choose the statement by what should be true afterwards (add rows, replace these partitions, replace what matches this filter, or reconcile against a changelog) and let the mode properties decide how it is physically done.
Then remember that ingestion is half the job: the files you write are the files someone has to compact.
References
- Iceberg Spark writes for the write API and row-level SQL
- Iceberg configuration for distribution mode and file sizing
SparkWriteConfandBaseRowDeltafor the default distribution mode and the snapshot operation a row-level write records- Copy-on-write or merge-on-read for how row-level statements are executed
- Streaming into Iceberg for the continuous case
- CDC into Iceberg for MERGE applied repeatedly
Trademarks
Apache Iceberg, Apache Spark, Apache and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation in the United States and other countries.
Found this useful?
These posts and tools are free. If one saved you an afternoon, you can buy me a coffee.