Summary
Transaction.upsert should be reimplemented as a single-pass, file-at-a-time operation. The current implementation encodes the source keyset as a BooleanExpression up front and hands that expression to every downstream stage. That expression is expensive to build, is evaluated many times, and does not reduce the data read below what per-file Iceberg metadata already allows. Every reported upsert bottleneck (slow filter construction, PyArrow segfaults on composite keys, full-table scans in delete(), double reads, unbounded memory, multiple snapshots) traces back to that design.
The proposal keeps the public API unchanged and treats pruning as an optional optimization layer derived directly from the source keyset's statistics, never from an n-term predicate.
Background: how upsert works today
create_match_filter turns every source key into a node of a Python expression tree. For composite keys it emits one And(...) disjunct per distinct key tuple.
- That expression is bound, projected, walked by the manifest evaluator, walked again by the strict and inclusive metrics evaluators for every data file, converted to an Arrow expression, canonicalized by Acero, and evaluated per row group.
- Matched rows are read with every column materialized, then compared to the source in a Python loop (
slice + .as_py() per cell) because Arrow cannot join or compare nested types.
overwrite() calls delete(), which re-plans the same files against the same expression, loads each candidate file fully into memory with no row-filter pushdown, filters it, and rewrites it sequentially.
- The result is up to three snapshots:
DELETE, OVERWRITE, APPEND.
What the community has measured
| Stage |
Reported cost |
| Build the expression, 1M keys |
10 s before any I/O |
| Acero canonicalization, 2 or 3 join columns |
SIGSEGV at ~10k keys |
| Scan with the expression, 2M-row table |
382 s |
txn.delete() with the expression, 1M keys |
1054 s of 1066 s total |
| Same table, successive upserts |
12 min, 31 min, 51 min as file count grows |
| Python row diff, 20k rows x 200 columns |
20 s (0.15 s vectorized) |
| Memory, 18.5 GB table on Lambda |
> 10 GB, OOM |
Why the fixes so far have not landed
Every proposed fix optimizes one stage of the pipeline while keeping the pipeline: shrink or reshape the expression, read fewer columns, speed up the Python diff, or drop the diff. None of them changes the three structural costs:
- the keyset is a predicate, so every stage scales with source rows x target files
- matched files are read twice, the second time without pushdown
delete() rewrites whole files, one at a time, fully in memory
Key insight
The simplest correct upsert compares the source against every live data file in the table. The boolean expression was only ever a way to skip files, and skipping files can be done directly from the source keyset with Iceberg metadata: partition values and column bounds. Pruning is an optimization on top of the baseline, not the mechanism for matching.
Proposed design
Baseline algorithm
Inputs: source table cast to the current table schema, join columns, when_matched_update_all, when_not_matched_insert_all.
- Plan candidate files. Start from every data file on the target branch with delete files attached (
DataScan.plan_files with ALWAYS_TRUE). The pruning layer filters this list.
- Read each candidate file once, projected to the current table schema via
ArrowScan.to_table(tasks=[one file]), the same way delete() reads files today. One worker holds one file, which also sidesteps the eager prefetch behavior of the batch reader.
- Match in Arrow, not through a predicate. Inner-join the file's key columns against the source key columns with row-index markers. Join columns must be primitive, non-null, non-float types; reject anything else up front with a clear error. Nested types are fine in non-key columns because they are never joined.
- Diff vectorized. Per non-key column,
not_equal with Kleene null handling, recursing into struct fields. Python fallback only for list and map columns.
- Decide per file. If no matched row changed, the file is untouched. Otherwise the replacement file is the target rows minus the changed matched rows, written via
_dataframe_to_data_files. Record matched source indices in a per-source-row counter.
- After all files. Source rows with a nonzero counter and a detected change form the update set. Source rows with a zero counter form the insert set. A counter above one means the target has duplicate keys and the upsert should abort. Write update and insert rows together through the normal partitioned writer once.
- Commit one
OVERWRITE snapshot using the existing _OverwriteFiles producer: delete_data_file for each replaced original, append_data_file for each rewritten and new file. Existing replaced-file validation covers the commit-retry path.
When when_matched_update_all=False, step 2 only needs the join columns projected and step 5 never rewrites, so the operation degenerates to a pure APPEND of unmatched rows.
Pruning as an optimization layer
All three levels are computed once from the source keyset and never build an expression.
| Level |
Signal |
How to skip |
Cost per unit |
| Manifest |
partition field summaries |
For each partition field whose source column is a join column, apply the partition transform to the source values to get a set of partition values. Skip the manifest if its summary range for that field is disjoint from the set's min and max. |
O(partition fields) |
| Data file, partition |
DataFile.partition |
Exact membership check of the file's partition tuple against the transformed set. Compare in partition space, not by projecting a row-space predicate. |
O(partition fields) |
| Data file, column bounds |
lower_bounds / upper_bounds for join column field IDs |
Decode the bounds, binary-search the sorted source values for that column. Skip if no value lands in the range. Composite keys are checked per column independently, which is conservative but never explodes. |
O(join cols x log n) |
Two rules keep this correct:
- Partition pruning may only use partition fields derived from join columns. A source row's own partition value says nothing about where the old row lives; using it silently turns an update into a duplicate insert when a key moves partitions.
- Pruning must be a pure filter over the candidate list. Turning it off yields the baseline, and tests should run both ways.
The column-bounds check is what _InclusiveMetricsEvaluator computes for an In predicate, minus walking n nodes per file.
Expected cost
| Stage |
Today |
Proposed |
| Before I/O |
O(n) Python nodes, then bind and project |
sort join columns, transform partition values |
| File selection |
walk n-node tree per manifest and per file, three evaluators |
O(cols x log n) per file |
| Row filtering |
Arrow canonicalizes n disjuncts, segfaults at ~10k composite keys |
hash join per file |
| Data reads |
every candidate read twice, second time without pushdown |
once |
| Rewrite |
sequential, whole file in memory |
per file, parallel by worker count |
| Snapshots |
DELETE, OVERWRITE, APPEND |
one OVERWRITE |
What this fixes
- Keyset-as-predicate construction and evaluation are gone.
- Double scan and full-file loads in
delete() are gone.
- Python cell loops are gone except for list and map columns.
- Memory is bounded by
workers x one file + source.
- Snapshot count drops to one, which restores atomicity from the reader's perspective and works with catalogs that reject multi-snapshot commits.
- Schema evolution works because every file is projected to the current schema and the source is cast once.
- Cross-file duplicate detection in the target becomes possible via the per-source-row counter.
What this does not fix
- A key whose partition column is not a join column still requires scanning every file that column-bounds pruning cannot exclude. Only equality deletes remove that limit. The design leaves a seam: step 5 could emit an equality delete file instead of a rewrite in the future.
- Nested or null join keys are rejected, not supported.
- Source keys must fit in memory, as today.
Alternatives considered
- Patch in place (vectorize the diff, fix schema-evolution projection, regroup the composite-key filter). Cheap and worth doing regardless, but leaves the
source x files scaling, the double read, and the whole-file loads untouched.
- Engine delegation (DataFusion / iceberg-rust). Right long-term direction, not available now. The per-file step in this design (source + target file in, rewritten rows + matched indices out) is the natural pluggable boundary and keeps all PyArrow calls in
io/pyarrow.py.
- Equality-delete write path. Solves partition migration and rewrite cost but adds compaction and read-side obligations. Defer, keep the seam.
Open decisions
- One
OVERWRITE snapshot vs. the current three. Separate snapshots were argued to help other clients detect conflicts. A single snapshot fixes atomicity and multi-snapshot catalog rejections, and the manifest entries still record every deleted and added file. Recommendation: single snapshot.
- Null join keys. Reject with a clear error, or silently never match. Recommendation: reject, since silent non-matching creates duplicates on the next run.
- Fate of
pyiceberg.table.upsert_util. It is public by accident. Dissolve it into io/pyarrow.py with deprecation shims.
Suggested PR sequence
- Keyset pruning helpers (column-bounds and partition-space) with unit tests covering every partition transform.
- Per-file match-and-diff function in
io/pyarrow.py, with the vectorized compare and the join-column type guard.
- New
Transaction.upsert body on the _OverwriteFiles producer, with pruning switchable off in tests. Fold in existing regression tests for schema evolution, composite-key segfaults, wide tables, and partition transforms.
- Deprecate
upsert_util.
Summary
Transaction.upsertshould be reimplemented as a single-pass, file-at-a-time operation. The current implementation encodes the source keyset as aBooleanExpressionup front and hands that expression to every downstream stage. That expression is expensive to build, is evaluated many times, and does not reduce the data read below what per-file Iceberg metadata already allows. Every reported upsert bottleneck (slow filter construction, PyArrow segfaults on composite keys, full-table scans indelete(), double reads, unbounded memory, multiple snapshots) traces back to that design.The proposal keeps the public API unchanged and treats pruning as an optional optimization layer derived directly from the source keyset's statistics, never from an n-term predicate.
Background: how upsert works today
create_match_filterturns every source key into a node of a Python expression tree. For composite keys it emits oneAnd(...)disjunct per distinct key tuple.slice+.as_py()per cell) because Arrow cannot join or compare nested types.overwrite()callsdelete(), which re-plans the same files against the same expression, loads each candidate file fully into memory with no row-filter pushdown, filters it, and rewrites it sequentially.DELETE,OVERWRITE,APPEND.What the community has measured
txn.delete()with the expression, 1M keysWhy the fixes so far have not landed
Every proposed fix optimizes one stage of the pipeline while keeping the pipeline: shrink or reshape the expression, read fewer columns, speed up the Python diff, or drop the diff. None of them changes the three structural costs:
delete()rewrites whole files, one at a time, fully in memoryKey insight
The simplest correct upsert compares the source against every live data file in the table. The boolean expression was only ever a way to skip files, and skipping files can be done directly from the source keyset with Iceberg metadata: partition values and column bounds. Pruning is an optimization on top of the baseline, not the mechanism for matching.
Proposed design
Baseline algorithm
Inputs: source table cast to the current table schema, join columns,
when_matched_update_all,when_not_matched_insert_all.DataScan.plan_fileswithALWAYS_TRUE). The pruning layer filters this list.ArrowScan.to_table(tasks=[one file]), the same waydelete()reads files today. One worker holds one file, which also sidesteps the eager prefetch behavior of the batch reader.not_equalwith Kleene null handling, recursing into struct fields. Python fallback only for list and map columns._dataframe_to_data_files. Record matched source indices in a per-source-row counter.OVERWRITEsnapshot using the existing_OverwriteFilesproducer:delete_data_filefor each replaced original,append_data_filefor each rewritten and new file. Existing replaced-file validation covers the commit-retry path.When
when_matched_update_all=False, step 2 only needs the join columns projected and step 5 never rewrites, so the operation degenerates to a pureAPPENDof unmatched rows.Pruning as an optimization layer
All three levels are computed once from the source keyset and never build an expression.
DataFile.partitionlower_bounds/upper_boundsfor join column field IDsTwo rules keep this correct:
The column-bounds check is what
_InclusiveMetricsEvaluatorcomputes for anInpredicate, minus walking n nodes per file.Expected cost
DELETE,OVERWRITE,APPENDOVERWRITEWhat this fixes
delete()are gone.workers x one file + source.What this does not fix
Alternatives considered
source x filesscaling, the double read, and the whole-file loads untouched.io/pyarrow.py.Open decisions
OVERWRITEsnapshot vs. the current three. Separate snapshots were argued to help other clients detect conflicts. A single snapshot fixes atomicity and multi-snapshot catalog rejections, and the manifest entries still record every deleted and added file. Recommendation: single snapshot.pyiceberg.table.upsert_util. It is public by accident. Dissolve it intoio/pyarrow.pywith deprecation shims.Suggested PR sequence
io/pyarrow.py, with the vectorized compare and the join-column type guard.Transaction.upsertbody on the_OverwriteFilesproducer, with pruning switchable off in tests. Fold in existing regression tests for schema evolution, composite-key segfaults, wide tables, and partition transforms.upsert_util.