Skip to content

Commit a48e0cd

Browse files
committed
feat: Support difference_cols for upsert update detection (#3598)
1 parent 68898e5 commit a48e0cd

3 files changed

Lines changed: 344 additions & 4 deletions

File tree

pyiceberg/table/__init__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,7 @@ def upsert(
848848
case_sensitive: bool = True,
849849
branch: str | None = MAIN_BRANCH,
850850
snapshot_properties: dict[str, str] = EMPTY_DICT,
851+
difference_cols: list[str] | None = None,
851852
) -> UpsertResult:
852853
"""Shorthand API for performing an upsert to an iceberg table.
853854
@@ -862,6 +863,11 @@ def upsert(
862863
case_sensitive: Bool indicating if the match should be case-sensitive
863864
branch: Branch Reference to run the upsert operation
864865
snapshot_properties: Custom properties to be added to the snapshot summary
866+
difference_cols: Subset of non-key columns to compare when detecting changed rows
867+
(e.g. a hash column that reflects any change to the row). This only limits change
868+
*detection*: when a matched row is detected as changed, all of its columns are
869+
written, not just the listed ones. Changes to columns outside difference_cols are
870+
intentionally ignored for update detection. If not provided, all non-key columns are compared.
865871
866872
To learn more about the identifier-field-ids: https://iceberg.apache.org/spec/#identifier-field-ids
867873
@@ -913,6 +919,9 @@ def upsert(
913919
if upsert_util.has_duplicate_rows(df, join_cols):
914920
raise ValueError("Duplicate rows found in source dataset based on the key columns. No upsert executed")
915921

922+
# Fail fast on invalid difference_cols instead of erroring on the first matched batch
923+
upsert_util.validate_difference_cols(df.column_names, join_cols, difference_cols)
924+
916925
from pyiceberg.io.pyarrow import _check_pyarrow_schema_compatible
917926

918927
downcast_ns_timestamp_to_us = Config().get_bool(DOWNCAST_NS_TIMESTAMP_TO_US_ON_WRITE) or False
@@ -952,7 +961,7 @@ def upsert(
952961
# values have actually changed. We don't want to do just a blanket overwrite for matched
953962
# rows if the actual non-key column data hasn't changed.
954963
# this extra step avoids unnecessary IO and writes
955-
rows_to_update = upsert_util.get_rows_to_update(df, rows, join_cols)
964+
rows_to_update = upsert_util.get_rows_to_update(df, rows, join_cols, difference_cols)
956965

957966
if len(rows_to_update) > 0:
958967
# build the match predicate filter
@@ -1653,6 +1662,7 @@ def upsert(
16531662
case_sensitive: bool = True,
16541663
branch: str | None = MAIN_BRANCH,
16551664
snapshot_properties: dict[str, str] = EMPTY_DICT,
1665+
difference_cols: list[str] | None = None,
16561666
) -> UpsertResult:
16571667
"""Shorthand API for performing an upsert to an iceberg table.
16581668
@@ -1667,6 +1677,11 @@ def upsert(
16671677
case_sensitive: Bool indicating if the match should be case-sensitive
16681678
branch: Branch Reference to run the upsert operation
16691679
snapshot_properties: Custom properties to be added to the snapshot summary
1680+
difference_cols: Subset of non-key columns to compare when detecting changed rows
1681+
(e.g. a hash column that reflects any change to the row). This only limits change
1682+
*detection*: when a matched row is detected as changed, all of its columns are
1683+
written, not just the listed ones. Changes to columns outside difference_cols are
1684+
intentionally ignored for update detection. If not provided, all non-key columns are compared.
16701685
16711686
To learn more about the identifier-field-ids: https://iceberg.apache.org/spec/#identifier-field-ids
16721687
@@ -1701,6 +1716,7 @@ def upsert(
17011716
case_sensitive=case_sensitive,
17021717
branch=branch,
17031718
snapshot_properties=snapshot_properties,
1719+
difference_cols=difference_cols,
17041720
)
17051721

17061722
def append(

pyiceberg/table/upsert_util.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,50 @@ def has_duplicate_rows(df: pyarrow_table, join_cols: list[str]) -> bool:
5353
return len(df.select(join_cols).group_by(join_cols).aggregate([([], "count_all")]).filter(pc.field("count_all") > 1)) > 0
5454

5555

56-
def get_rows_to_update(source_table: pa.Table, target_table: pa.Table, join_cols: list[str]) -> pa.Table:
56+
def validate_difference_cols(column_names: list[str], join_cols: list[str], difference_cols: list[str] | None) -> None:
57+
"""Validate the columns used to detect changes in matched rows.
58+
59+
These columns must be non-key columns used for change detection.
60+
61+
Raises:
62+
ValueError: If `difference_cols` is empty, contains columns that are not present
63+
in `column_names`, or overlaps with `join_cols`.
64+
"""
65+
if difference_cols is None:
66+
return
67+
68+
difference_cols_set = set(difference_cols)
69+
70+
if not difference_cols_set:
71+
raise ValueError("difference_cols must contain at least one column; use None to compare all non-key columns")
72+
73+
if unknown_cols := difference_cols_set - set(column_names):
74+
raise ValueError(f"Columns in difference_cols could not be found in the source table: {sorted(unknown_cols)}")
75+
76+
if key_cols := difference_cols_set & set(join_cols):
77+
raise ValueError(f"Columns in difference_cols cannot be join columns: {sorted(key_cols)}")
78+
79+
80+
def get_rows_to_update(
81+
source_table: pa.Table, target_table: pa.Table, join_cols: list[str], difference_cols: list[str] | None = None
82+
) -> pa.Table:
5783
"""
5884
Return a table with rows that need to be updated in the target table based on the join columns.
5985
6086
The table is joined on the identifier columns, and then checked if there are any updated rows.
6187
Those are selected and everything is renamed correctly.
88+
89+
When `difference_cols` is provided, only those non-key columns are compared to detect changes in
90+
matched rows, instead of all non-key columns. Changes to columns outside `difference_cols`
91+
are intentionally ignored for update detection. This only affects change *detection*: rows
92+
that are detected as changed are still returned with all of their columns.
6293
"""
6394
all_columns = set(source_table.column_names)
6495
join_cols_set = set(join_cols)
6596

66-
non_key_cols = list(all_columns - join_cols_set)
97+
validate_difference_cols(source_table.column_names, join_cols, difference_cols)
98+
99+
non_key_cols = list(all_columns - join_cols_set) if difference_cols is None else difference_cols
67100

68101
if has_duplicate_rows(target_table, join_cols):
69102
raise ValueError("Target table has duplicate rows, aborting upsert")

0 commit comments

Comments
 (0)