Module 6: Merge Into And Native Upserts
PyIceberg's upsert: the Python-native alternative
Description
This lesson really runs, with no caveats at all, what lessons 4 and 5 couldn't execute in this environment: the same MERGE INTO idea — update if it exists, insert if it doesn't — but as a single Python method, table.upsert(), with not one line of SQL and no need for the JVM. You're going to create a new table, load it with V1, and confirm, with literal output, that table.upsert() detects P002's real change — and only that one — among four rows you hand it complete.
Connection to the module. Lesson 3 documented a real incompatibility between iceberg-spark-runtime-4.0 and Spark 4.2.0, which left lessons 4 and 5 marked representative. table.upsert() doesn't depend on Spark at all — it lives entirely in PyIceberg, the same package you've already used without interruption in modules 1 through 5 — so this lesson returns to this guide's discipline: really executed code, literally copied output.
Why this lesson doesn't touch kiosko.dim_product
Before the code, a necessary clarification. kiosko.dim_product — the real table, the one you built in module 3 — has already been living in its V2 state for three modules: table.overwrite() already applied the change, and snap_v1 is still available to recover the previous state via time travel. Repeating the same change on that same table wouldn't demonstrate anything new — upsert() would compare V2 against V2, and would find no difference to update.
To compare upsert() with this module's other four techniques, on equal footing — starting from V1, just like lesson 5 did (with local.kiosko.dim_product, in a separate catalog) and the DuckDB and dbt techniques (lesson 2) — this lesson reproduces the experiment on a new, dedicated table: kiosko.dim_product_upsert_demo. Same kiosko catalog as always, new table, so as not to touch the real history you already built. It's the same discipline you already saw in each module's closing projects: a new working directory, a state reproducible from scratch.
An analogy: the same counter, now in self-service mode
Lesson 1's bank counter still needed someone staffing it with SQL. table.upsert() is that same window, turned into a self-service kiosk: you hand it the complete state you believe is correct — all four rows, exactly as you know them today — and the system decides, on its own, which of those rows represent a real change and which were already up to date, with no need for you to calculate the delta in advance or write an ON/WHEN MATCHED.
Worked example: upsert(), really executed
Step 1 — Create the demo table, loaded with V1
# upsert_demo.py
import os
import pyarrow as pa
from pyiceberg.catalog import load_catalog
from pyiceberg.schema import Schema
from pyiceberg.types import DoubleType, NestedField, StringType
warehouse_path = os.path.abspath("kiosko_warehouse")
catalog_db_path = os.path.abspath("kiosko_catalog.db")
catalog = load_catalog(
"kiosko", type="sql",
uri=f"sqlite:///{catalog_db_path}", warehouse=f"file://{warehouse_path}",
)
catalog.create_namespace("kiosko")
dim_product_schema = Schema(
NestedField(field_id=1, name="product_id", field_type=StringType(), required=True),
NestedField(field_id=2, name="product_name", field_type=StringType(), required=True),
NestedField(field_id=3, name="category", field_type=StringType(), required=True),
NestedField(field_id=4, name="unit_cost", field_type=DoubleType(), required=True),
)
table = catalog.create_table("kiosko.dim_product_upsert_demo", schema=dim_product_schema)
dim_product_pa_schema = pa.schema([
pa.field("product_id", pa.string(), nullable=False),
pa.field("product_name", pa.string(), nullable=False),
pa.field("category", pa.string(), nullable=False),
pa.field("unit_cost", pa.float64(), nullable=False),
])
DIM_PRODUCT_V1 = [
{"product_id": "P001", "product_name": "Bottled Water 600ml", "category": "beverages", "unit_cost": 0.40},
{"product_id": "P002", "product_name": "Energy Bar", "category": "snacks", "unit_cost": 0.60},
{"product_id": "P003", "product_name": "Instant Coffee Sachet", "category": "beverages", "unit_cost": 0.35},
{"product_id": "P004", "product_name": "Phone Charger Cable", "category": "electronics", "unit_cost": 2.10},
]
pa_table_v1 = pa.Table.from_pylist(DIM_PRODUCT_V1, schema=dim_product_pa_schema)
table.append(pa_table_v1)
print("V1 loaded. Rows:", table.scan().to_arrow().num_rows)
What to expect (verified by running the real script):
V1 loaded. Rows: 4
Nothing new up to here — the same create_table() + append() you've already used dozens of times since module 1.
Step 2 — table.upsert(), with the complete V2 state
DIM_PRODUCT_V2 = [
{"product_id": "P001", "product_name": "Bottled Water 600ml", "category": "beverages", "unit_cost": 0.40},
{"product_id": "P002", "product_name": "Energy Bar", "category": "health-snacks", "unit_cost": 0.68},
{"product_id": "P003", "product_name": "Instant Coffee Sachet", "category": "beverages", "unit_cost": 0.35},
{"product_id": "P004", "product_name": "Phone Charger Cable", "category": "electronics", "unit_cost": 2.10},
]
pa_table_v2 = pa.Table.from_pylist(DIM_PRODUCT_V2, schema=dim_product_pa_schema)
result = table.upsert(pa_table_v2, join_cols=["product_id"])
print("UpsertResult:", result)
print("rows_updated:", result.rows_updated)
print("rows_inserted:", result.rows_inserted)
Notice something different from lesson 5: here you hand upsert() all four complete rows — not just the delta — exactly like data-modeling and dbt did in lesson 2. The difference is that upsert(), unlike MERGE INTO as you wrote it in lesson 5, does compare internally every non-key value against what already exists, and only counts as "updated" the row where something really changed.
What to expect (verified by running the real script):
UpsertResult: UpsertResult(rows_updated=1, rows_inserted=0)
rows_updated: 1
rows_inserted: 0
Exactly what you predicted in lesson 2's Exercise 3: one row updated — P002, the only one that changed — zero rows inserted — no new product_id. P001, P003, and P004 were present in the four rows you handed it, but upsert() recognized them as identical to what was already on file, and didn't touch them.
Step 3 — Confirm the final state
print("\ntable.scan().to_arrow() after the upsert (sorted by product_id):")
rows = sorted(table.scan().to_arrow().to_pylist(), key=lambda r: r["product_id"])
for row in rows:
print(f" {row['product_id']} {row['product_name']:<22} {row['category']:<14} unit_cost={row['unit_cost']}")
What to expect (verified by running the real script):
table.scan().to_arrow() after the upsert (sorted by product_id):
P001 Bottled Water 600ml beverages unit_cost=0.4
P002 Energy Bar health-snacks unit_cost=0.68
P003 Instant Coffee Sachet beverages unit_cost=0.35
P004 Phone Charger Cable electronics unit_cost=2.1
A note on order. If you run table.scan().to_arrow().to_pylist() without explicitly sorting, P002's row shows up first, not in the P001..P004 order you saw in module 3 — because upsert() wrote a new, separate data file, with only the updated row, and table.scan() doesn't guarantee any order across files. This guide never assumed an implicit order in any previous lesson by accident: table.scan() reflects the physical order in which Iceberg reads its data files, never an ORDER BY — if your code depends on a specific order, sort it yourself, exactly like sorted(...) does in this step.
Step 4 — What table.history() revealed: two snapshots, not one
print("\ntable.history() has", len(table.history()), "entries")
snaps = table.inspect.snapshots().select(["operation", "summary"])
for row in snaps.to_pylist():
s = dict(row["summary"])
print(f" operation={row['operation']:<9} added-records={s.get('added-records','-')} "
f"deleted-records={s.get('deleted-records','-')} total-records={s.get('total-records','-')}")
What to expect (verified by running the real script; snapshot_id/committed_at are from your own run, different every time):
table.history() has 3 entries
operation=append added-records=4 deleted-records=- total-records=4
operation=overwrite added-records=3 deleted-records=4 total-records=-
operation=append added-records=1 deleted-records=- total-records=4
Three entries: Step 1's append() (the four V1 rows), and two more, not one, produced by the single upsert() call in Step 2. This shouldn't entirely surprise you — it's the same lesson you already learned in module 3, lesson 3, now applied to a different mechanism: a high-level PyIceberg operation can internally resolve into several snapshots.
Going deeper: what upsert() does under the hood, with evidence from the source code itself
It's worth understanding why exactly those two entries show up, not one and not three. PyIceberg 0.11.1's implementation of table.upsert() — open source, verifiable — essentially does two steps:
- It identifies which
sourcerows match atargetrow and have at least one different non-key value — the same row-by-row comparison you already saw in Step 2, the reasonrows_updated=1and not4. With those rows — onlyP002, in this case — it internally callstable.overwrite(rows_to_update, overwrite_filter=...), with a filter that removes only the rows that match byproduct_id. Since those rows live together, in the same Parquet file, with the three that didn't change, Iceberg has to rewrite that file — it removes the old file's reference entirely (deleted-records=4) and writes a new one, with only the three rows that weren't going to be updated (added-records=3). This is theoperation=overwriteentry in the output above. - It adds the updated rows as a separate
append()— the newP002row, with itshealth-snacks/0.68values (added-records=1). This is the secondoperation=appendentry.
Two snapshots, one for "what stays the same, rearranged" and another for "what changed, added" — a different mechanism from module 3's full overwrite() (which did a delete of everything followed by an append of everything), but with the same underlying lesson: always count snapshots with table.history(), never assume "one call, one snapshot."
Diagram: two rows stay put, one moves
flowchart TD
A["table.upsert(full_V2, join_cols=['product_id'])"] --> B["Internal step 1:\ncompares V2 against target\nonly P002 has a different value"]
B --> C["overwrite(only new P002,\nfilter=product_id=='P002')\nrewrites the file: P001,P003,P004 remain"]
B --> D["append(P002 health-snacks/0.68)\nnew row, new file"]
C --> E["UpsertResult\nrows_updated=1, rows_inserted=0"]
D --> E
Common mistakes
Calling table.upsert() without join_cols, and expecting PyIceberg to guess the key. What happens: someone runs table.upsert(pa_table_v2), without the join_cols argument, trusting PyIceberg to use product_id automatically because it's "obviously" the key. Verify it yourself:
table.upsert(pa_table_v2) # without join_cols
ValueError: Join columns could not be found, please set identifier-field-ids or pass in explicitly.
Why it happens: upsert() can infer the join column automatically, but only if the table's schema explicitly declares identifier-field-ids — a formal marker of "this column uniquely identifies each row" — something this guide's dim_product schema never declared (not in module 3, not here). How to spot it: the error message is explicit and says exactly what's missing — join_cols or identifier-field-ids. How to fix it: pass join_cols=["product_id"] explicitly, like this lesson does — it's safer than depending on a schema configuration you'd have to remember to declare at table-creation time.
Assuming upsert() with the complete state (V2, four rows) is equivalent to module 3's table.overwrite(). What happens: someone sees this lesson passes all four rows to upsert(), just like overwrite() received all four complete rows in module 3, and concludes they're the same operation under a different name. Why it happens: both receive the same kind of input — the complete state. How to spot it: count the snapshots — module 3's overwrite() produced delete + append (removes everything, adds everything); this lesson's upsert() produced overwrite (partial, only rewrites the file that held the row that changed) + append (only the new row). How to fix it: upsert() always does, first, the work of comparing value by value — the same work the explicit WHEN MATCHED AND (...) had to do by hand in DuckDB's MERGE (lesson 2) — and only touches what really changed; overwrite() with no filter never compares anything, it simply replaces 100% of the table, whether something changed or not.
Exercises
Exercise 1 — Reproduce the full experiment yourself, and confirm the three numbers. With PyIceberg installed, run this lesson's four steps in a new directory. Confirm rows_updated=1, rows_inserted=0, and len(table.history()) == 3.
See solution
If your environment has PyIceberg 0.11.1 installed (pip install "pyiceberg[sql-sqlite,pyarrow]"), your output should match this lesson's exactly on all three numbers — rows_updated=1, rows_inserted=0, three entries in table.history(). The snapshot_ids are going to be different from the ones shown here — that's exactly what's expected.
Exercise 2 — Run upsert() a second time, with the same V2, and predict the result before running it. Without changing anything, call table.upsert(pa_table_v2, join_cols=["product_id"]) again, on the table this lesson left behind.
See solution
result2 = table.upsert(pa_table_v2, join_cols=["product_id"])
print("UpsertResult (second run):", result2)
Expected output: UpsertResult(rows_updated=0, rows_inserted=0). No row changes, because V2's four rows are already identical to what the table has on file — the same idempotence property you already saw in DuckDB's MERGE (lesson 2) and in dbt snapshot, now confirmed for upsert() too. table.history() would stay at three entries — a run with no real changes adds no new snapshot at all, because get_rows_to_update() finds no row to rewrite and rows_to_insert stays empty.
Exercise 3 — Explain, in your own words, why upsert() needed to rewrite an entire file (deleted-records=4) to update a single row. In 2-3 sentences, using what you know about Parquet files from module 2, explain why "updating a row" in Iceberg almost never means "touching only that row" at the physical file level.
See solution
Parquet is an immutable file format — once written, no process can modify a specific row inside an existing .parquet file without rewriting it in full. Since all four V1 rows live together in a single data file (all four got loaded with one append() in Step 1), updating P002's value forces Iceberg to remove the reference to that entire file (deleted-records=4) and write a new one with the rows that really survive unchanged (added-records=3), while the updated row gets archived separately. It's the same principle you already saw in module 2 — data files never get modified in place, they only get replaced entirely — applied here to a single-row update.
Summary and next step
In this lesson you really ran table.upsert(), with no caveats at all: you created kiosko.dim_product_upsert_demo from V1, and confirmed that upsert(full_V2, join_cols=["product_id"]) detects, on its own, that only P002 changed — UpsertResult(rows_updated=1, rows_inserted=0) — with no need for you to calculate any delta in advance. You saw, with table.history(), that this single call produced two internal snapshots — a partial overwrite and an append — and why, by reading PyIceberg's own source code.
Before moving on you should be able to: explain why this lesson uses a new table instead of kiosko.dim_product; reproduce the full experiment with the exact three numbers; and explain the difference between upsert() (compares and only touches what changed) and MERGE INTO as you wrote it in lesson 5 (updates any match, without comparing values, unless you add that condition by hand).
Lesson 7 gives the final criterion: when to choose MERGE INTO in SQL, when to choose upsert() in Python — and when neither of the two, and you still need module 3's table.overwrite().
Resources
- PyIceberg — API reference,
table.upsert(),UpsertResult, and thejoin_colsargument. py.iceberg.apache.org/api. In English. - PyIceberg — PyPI, current version 0.11.1, the same version you installed in module 1 and that runs this lesson with no changes. pypi.org/project/pyiceberg. In English.
- This same guide, module 3, lesson 3 — source of the original finding that one operation can produce more than one snapshot, revisited here for
upsert().03-changing-p002-with-a-plain-overwrite.md. In Spanish. - This guide's DESIGN doc — the full map of the eight modules, including module 6's section, and the hard rule to never hardcode a
snapshot_id.src/guides/lakehouse-and-iceberg-guide/DISENO.md. In Spanish.