All posts

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.

27 min read Iceberg

TL;DR

  • Four ways in: append, overwritePartitions, overwrite with a filter, and row-level SQL (UPDATE, DELETE, MERGE INTO).
  • A single MERGE INTO applied 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 operation in the snapshot log: append for added files, overwrite when files were replaced, delete when files were only removed or only delete files were added. A filtered overwrite with no rows, and a DELETE that drops whole files, are both delete.
  • Write through the catalog: writeTo(...), or save() with the table’s name. save() with the table’s storage path failed outright on a Hive-catalog table.
  • MERGE fails 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 with none.

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_ts as a string and no amount_usd column 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_files defaults 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 ... PURGE deleted 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: 0
    

    If 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

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.

Buy me a coffee