This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 847a717d77 [python] Expand Native upserts, Dictionary MERGE and 
Parquet vector updates (#10629)
847a717d77 is described below

commit 847a717d77968a1c9bd334c3d329cdbdd69754f7
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 17 21:19:27 2026 +0800

    [python] Expand Native upserts, Dictionary MERGE and Parquet vector updates 
(#10629)
---
 .../tests/native_merge_dictionary_keys_test.py     | 161 ++++++++++++
 .../tests/native_parquet_vector_update_test.py     | 235 +++++++++++++++++
 .../tests/native_upsert_key_normalization_test.py  | 232 +++++++++++++++++
 .../tests/native_upsert_selected_values_test.py    | 283 +++++++++++++++++++++
 paimon-python/pypaimon/write/native_merge_into.py  |   9 +-
 paimon-python/pypaimon/write/native_update.py      |  41 ++-
 paimon-python/pypaimon/write/table_update.py       |   4 +-
 7 files changed, 952 insertions(+), 13 deletions(-)

diff --git a/paimon-python/pypaimon/tests/native_merge_dictionary_keys_test.py 
b/paimon-python/pypaimon/tests/native_merge_dictionary_keys_test.py
new file mode 100644
index 0000000000..9065db1ae1
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_merge_dictionary_keys_test.py
@@ -0,0 +1,161 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Native MERGE must join dictionary values, never dictionary indices."""
+
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.table.data_evolution_merge_into import WhenMatched, 
WhenNotMatched, lit, source_col
+
+
+pytestmark = pytest.mark.native_plan
+_KEYS = [(pa.int32(), 1, 2, 3), (pa.string(), '中文', 'keep', 'new'),
+         (pa.binary(), b'key', b'keep', b'new')]
+
+
+def _dictionary(values, data_type):
+    return pa.array(values, type=data_type).dictionary_encode()
+
+
+def _table(tmp_path, native, key_type, key, other):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('db', True)
+    schema = pa.schema([('key', key_type), ('pt', pa.string()), ('value', 
pa.int32()), ('keep', pa.int32())])
+    catalog.create_table('db.t', Schema.from_pyarrow_schema(schema, 
partition_keys=['pt'], options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'deletion-vectors.enabled': 'true', 'write.native.enabled': 
str(native).lower(),
+    }), False)
+    table = catalog.get_table('db.t')
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.table({'key': [key, key, other, None], 'pt': 
['a'] * 4,
+                                    'value': [0] * 4, 'keep': [0, 1, 2, 3]}, 
schema=schema))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return table
+
+
+def _source(key_type, values):
+    source = pa.table({'match_key': _dictionary([key for key, _ in values], 
key_type),
+                       'match_pt': _dictionary(['a'] * len(values), 
pa.string()),
+                       'value': pa.array([value for _, value in values], 
type=pa.int32())})
+    # Retain source chunks while using the shared dictionaries required by
+    # PyArrow's reference join. Rust's independent dictionaries have core 
tests.
+    return pa.concat_tables([source.slice(index, 1) for index in 
range(len(values))])
+
+
+def _merge(table, source, native, stream, matched, inserted=()):
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    with ExitStack() as stack:
+        if native:
+            
stack.enter_context(patch('pypaimon.table.data_evolution_merge_into._build_tables',
+                                      side_effect=AssertionError('Python MERGE 
join')))
+        update = builder.new_update()
+        kwargs = dict(on={'key': 'match_key', 'pt': 'match_pt'}, 
when_matched=matched, when_not_matched=inserted)
+        messages = update.merge_into(source, commit_identifier=53, **kwargs) 
if stream else update.merge_into(
+            source, **kwargs)
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 53) if stream else commit.commit(messages)
+    finally:
+        commit.close()
+
+
+def _rows(table):
+    for native in (False, True):
+        view = table.copy({'read.native.enabled': str(native).lower(),
+                           'scan.native-plan.enabled': str(native).lower()})
+        builder = view.new_read_builder().with_projection(['key', 'pt', 
'value', 'keep', '_ROW_ID'])
+        yield 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist()
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('key_type,key,other,new', _KEYS)
+def test_dictionary_merge_update_insert_and_null_key_semantics(tmp_path, 
native, stream, key_type, key, other, new):
+    table = _table(tmp_path, native, key_type, key, other)
+    before = {row['keep']: row['_ROW_ID'] for row in next(_rows(table))}
+    source = _source(key_type, [(key, 42), (new, 7), (None, 9)])
+    _merge(table, source, native, stream,
+           [WhenMatched.update({'value': source_col('value')}, 
condition='s.value > t.value')],
+           [WhenNotMatched({'key': source_col('match_key'), 'pt': 
source_col('match_pt'),
+                            'value': source_col('value'), 'keep': lit(None)})])
+    for rows in _rows(table):
+        existing = sorted([row for row in rows if row['keep'] is not None], 
key=lambda row: row['keep'])
+        assert [row['value'] for row in existing] == [42, 42, 0, 0]
+        assert {row['keep']: row['_ROW_ID'] for row in existing} == before
+        inserted = [row for row in rows if row['keep'] is None]
+        assert len(inserted) == 2
+        assert {(row['key'], row['value'], row['pt']) for row in inserted} == 
{(new, 7, 'a'), (None, 9, 'a')}
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('key_type,key,other,new', _KEYS)
+def test_dictionary_cardinality_uses_values_across_source_chunks(
+        tmp_path, native, key_type, key, other, new):
+    table = _table(tmp_path, native, key_type, key, other)
+    source = _source(key_type, [(key, 1), (new, 2), (key, 3)])
+    before = next(_rows(table))
+    files = set(tmp_path.rglob('*.parquet'))
+    with pytest.raises(ValueError, match='multiple source rows'):
+        _merge(table, source, native, False,
+               [WhenMatched.update({'value': source_col('value')}, 
condition='s.value = 99')])
+    assert set(tmp_path.rglob('*.parquet')) == files
+    for rows in _rows(table):
+        assert sorted(rows, key=lambda row: row['keep']) == sorted(before, 
key=lambda row: row['keep'])
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('key_type,key,other,new', _KEYS)
+def test_dictionary_unconditional_delete_accepts_duplicate_source_matches(
+        tmp_path, native, key_type, key, other, new):
+    table = _table(tmp_path, native, key_type, key, other)
+    source = _source(key_type, [(key, 1), (new, 2), (key, 3)])
+    _merge(table, source, native, False, [WhenMatched.delete()])
+    for rows in _rows(table):
+        assert sorted(row['keep'] for row in rows) == [2, 3]
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
+def test_pandas_categorical_merge_does_not_require_python_join(tmp_path, 
native):
+    pd = pytest.importorskip('pandas')
+    table = _table(tmp_path, native, pa.string(), '中文', 'keep')
+    source = pd.DataFrame({'match_key': pd.Categorical(['中文', 'new']),
+                           'match_pt': pd.Categorical(['a', 'a']), 'value': 
[42, 7]})
+    _merge(table, source, native, False, [WhenMatched.update({'value': 
source_col('value')})])
+    for rows in _rows(table):
+        assert [row['value'] for row in sorted(rows, key=lambda row: 
row['keep'])] == [42, 42, 0, 0]
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
+def 
test_dictionary_null_values_and_null_indices_do_not_match_in_merge(tmp_path, 
native, stream):
+    table = _table(tmp_path, native, pa.string(), 'key', 'other')
+    keys = pa.DictionaryArray.from_arrays(pa.array([0, 1, None], 
type=pa.int8()), pa.array([None, 'key']))
+    source = pa.table({'match_key': keys, 'match_pt': ['a'] * 3,
+                       'value': pa.array([9, 42, 11], type=pa.int32())})
+    _merge(table, source, native, stream, [WhenMatched.update({'value': 
source_col('value')})])
+    for rows in _rows(table):
+        assert [row['value'] for row in sorted(rows, key=lambda row: 
row['keep'])] == [42, 42, 0, 0]
diff --git a/paimon-python/pypaimon/tests/native_parquet_vector_update_test.py 
b/paimon-python/pypaimon/tests/native_parquet_vector_update_test.py
new file mode 100644
index 0000000000..90a6c1b4f8
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_parquet_vector_update_test.py
@@ -0,0 +1,235 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Parquet vector sidecars must not force row-level updates back to Python."""
+
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pyarrow.compute as pc
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.read.table_read import TableRead
+from pypaimon.schema.data_types import PyarrowFieldParser
+from pypaimon.table.data_evolution_merge_into import WhenMatched, 
WhenNotMatched, source_col
+from pypaimon.write.table_update import TableUpdate
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+
+
+pytestmark = pytest.mark.native_plan
+
+
+def _table(tmp_path, native):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('db', True)
+    schema = pa.schema([('id', pa.int32()), ('pt', pa.string()), ('score', 
pa.int32()),
+                        ('embedding', pa.list_(pa.float32(), 2)), ('payload', 
pa.large_binary())])
+    catalog.create_table('db.t', Schema.from_pyarrow_schema(schema, 
partition_keys=['pt'], options={
+        'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true', 
'deletion-vectors.enabled': 'true',
+        'file.format': 'parquet', 'vector.file.format': 'parquet',
+        'write.native.enabled': str(native).lower(), 'read.batch-size': '1',
+    }), False)
+    table = catalog.get_table('db.t')
+    schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+    rows = [dict(id=i, pt='a' if i <= 2 else 'b', score=i,
+                 embedding=[float(i), float(i + 1)], payload=('blob-%d' % 
i).encode()) for i in range(1, 5)]
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+        messages = writer.prepare_commit()
+        assert any(file.file_name.endswith('.vector.parquet') for message in 
messages for file in message.new_files)
+        assert any(file.file_name.endswith('.blob') for message in messages 
for file in message.new_files)
+        commit.commit(messages)
+    finally:
+        writer.close()
+        commit.close()
+    return table, schema
+
+
+def _rows(table):
+    for native in (False, True):
+        view = table.copy({'read.native.enabled': str(native).lower(),
+                           'scan.native-plan.enabled': str(native).lower()})
+        builder = view.new_read_builder().with_projection(table.field_names + 
['_ROW_ID'])
+        rows = 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist()
+        yield sorted(rows, key=lambda row: row['id'])
+
+
+def _native_only(native):
+    stack = ExitStack()
+    if native:
+        for owner, method in [(TableUpdateByRowId, 
'_load_existing_files_info'),
+                              (TableUpsertByKey, '_upsert_partition'),
+                              (TableUpdate, '_build_predicate_update_table')]:
+            stack.enter_context(patch.object(owner, method, 
side_effect=AssertionError('Python update fallback')))
+        
stack.enter_context(patch('pypaimon.table.data_evolution_merge_into._build_tables',
+                                  side_effect=AssertionError('Python merge 
fallback')))
+    return stack
+
+
+def _commit(builder, messages, stream):
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 71) if stream else commit.commit(messages)
+    finally:
+        commit.close()
+
+
+def _replacement(schema, columns, ids):
+    values = {'score': [42] * len(ids), 'embedding': [[9.0, 8.0]] * len(ids), 
'payload': [b'new'] * len(ids)}
+    return pa.table({name: pa.array(values[name], 
type=schema.field(name).type) for name in columns})
+
+
+def _update(table, schema, native, stream, operation, columns, before):
+    ids = {row['id']: row['_ROW_ID'] for row in before}
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(columns)
+    data = _replacement(schema, columns, [1, 3])
+    with _native_only(native):
+        if operation in ('row_id', 'incremental'):
+            data = data.append_column('_ROW_ID', pa.array([ids[1], ids[3]], 
type=pa.int64()))
+            if operation == 'row_id':
+                messages = (update.update_by_arrow_with_row_id(data, 71) if 
stream
+                            else update.update_by_arrow_with_row_id(data))
+            else:
+                updater = update.new_update_by_row_id(71) if stream else 
update.new_update_by_row_id()
+                messages = updater.update_columns(data, columns)
+        elif operation == 'upsert':
+            data = data.append_column('id', pa.array([1, 3], 
type=pa.int32())).append_column('pt', ['a', 'b'])
+            messages = (update.upsert_by_arrow_with_key(data, ['id'], 71) if 
stream
+                        else update.upsert_by_arrow_with_key(data, ['id']))
+        elif operation == 'predicate':
+            predicate = update.new_predicate_builder().is_in('id', [1, 3])
+            values = {'score': 42, 'embedding': [9.0, 8.0], 'payload': b'new'}
+            assignments = {name: values[name] for name in columns}
+            messages = (update.update_by_predicate(predicate, assignments, 71) 
if stream
+                        else update.update_by_predicate(predicate, 
assignments))
+        else:
+            data = data.append_column('id', pa.array([1, 3], 
type=pa.int32())).append_column('pt', ['a', 'b'])
+            kwargs = dict(on=['id', 'pt'], when_matched=[WhenMatched.update(
+                {name: source_col(name) for name in columns})])
+            messages = update.merge_into(data, commit_identifier=71, **kwargs) 
if stream else update.merge_into(
+                data, **kwargs)
+    _commit(builder, messages, stream)
+    return messages
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('operation', ['row_id', 'incremental', 'upsert', 
'predicate', 'merge'])
[email protected]('columns', [['score'], ['embedding'], ['score', 
'embedding', 'payload']])
+def test_parquet_vector_and_blob_partial_updates_use_native_core(
+        tmp_path, native, stream, operation, columns):
+    table, schema = _table(tmp_path, native)
+    before = next(_rows(table))
+    messages = _update(table, schema, native, stream, operation, columns, 
before)
+    files = [file for message in messages for file in message.new_files]
+    if native:
+        assert any(file.file_name.endswith('.vector.parquet') for file in 
files) == ('embedding' in columns)
+        assert any(file.file_name.endswith('.blob') for file in files) == 
('payload' in columns)
+        for file in files:
+            if file.file_name.endswith('.vector.parquet'):
+                assert file.write_cols == ['embedding']
+                assert file.first_row_id is not None
+    for rows in _rows(table):
+        expected = []
+        for original in before:
+            row = dict(original)
+            if row['id'] in (1, 3):
+                for name, value in [('score', 42), ('embedding', [9.0, 8.0]), 
('payload', b'new')]:
+                    if name in columns:
+                        row[name] = value
+            expected.append(row)
+        assert rows == expected
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('native_read', [False, True], ids=['python-read', 
'native-read'])
+def 
test_shard_normal_updates_preserve_parquet_vector_and_blob_sidecars(tmp_path, 
native, stream, native_read):
+    table, schema = _table(tmp_path, native)
+    table = table.copy({'read.native.enabled': str(native_read).lower(), 
'read.parallelism': '4'})
+    before = next(_rows(table))
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['score'])
+    update.with_read_projection(['id', 'score'])
+
+    def reversed_groups(self, readers):
+        # A valid completion order for parallel readers must not change which
+        # row receives an update. Read actual files, but finish the last group 
first.
+        for reader in reversed(readers):
+            try:
+                yield from reader
+            finally:
+                reader.close()
+
+    with ExitStack() as stack:
+        stack.enter_context(patch.object(TableRead, 
'_native_batches_parallel_streaming', reversed_groups))
+        if native:
+            
stack.enter_context(patch('pypaimon.write.table_update.ShardTableUpdator.__init__',
+                                      side_effect=AssertionError('Python shard 
fallback')))
+        shard = update.new_shard_updator(0, 1)
+        reader = shard.arrow_reader()
+        try:
+            for batch in reader:
+                scores = pc.add(batch.column('score'), pa.scalar(10, 
type=pa.int32()))
+                
shard.update_by_arrow_batch(pa.RecordBatch.from_arrays([scores], 
names=['score']))
+        finally:
+            reader.close()
+        messages = shard.prepare_commit()
+    _commit(builder, messages, stream)
+    for rows in _rows(table):
+        assert rows == [dict(row, score=row['score'] + 10) for row in before]
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
+def test_merge_reads_a_parquet_vector_source_in_native_core(tmp_path, native):
+    table, schema = _table(tmp_path, native)
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_table('db.source', Schema.from_pyarrow_schema(schema, 
partition_keys=['pt'], options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'file.format': 'parquet', 'vector.file.format': 'parquet',
+    }), False)
+    source = catalog.get_table('db.source')
+    seed = source.new_batch_write_builder()
+    writer = seed.new_write()
+    try:
+        writer.write_arrow(pa.Table.from_pylist([
+            dict(id=1, pt='a', score=42, embedding=[9.0, 8.0], payload=b'new'),
+            dict(id=5, pt='b', score=50, embedding=[5.0, 6.0], 
payload=b'insert'),
+        ], schema=schema))
+        seed.new_commit().commit(writer.prepare_commit())
+    finally:
+        writer.close()
+    before = next(_rows(table))
+    builder = table.new_batch_write_builder()
+    with _native_only(native):
+        messages = builder.new_update().merge_into(
+            source, on=['id', 'pt'],
+            when_matched=[WhenMatched.update({name: source_col(name) for name 
in ('score', 'embedding', 'payload')})],
+            when_not_matched=[WhenNotMatched('*')])
+    _commit(builder, messages, False)
+    for rows in _rows(table):
+        assert len(rows) == 5
+        assert rows[0] == dict(before[0], score=42, embedding=[9.0, 8.0], 
payload=b'new')
+        assert rows[1:4] == before[1:]
+        assert {k: v for k, v in rows[4].items() if k != '_ROW_ID'} == dict(
+            id=5, pt='b', score=50, embedding=[5.0, 6.0], payload=b'insert')
diff --git 
a/paimon-python/pypaimon/tests/native_upsert_key_normalization_test.py 
b/paimon-python/pypaimon/tests/native_upsert_key_normalization_test.py
new file mode 100644
index 0000000000..004c653269
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_upsert_key_normalization_test.py
@@ -0,0 +1,232 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Logical upsert keys must not depend on Arrow widths or dictionaries."""
+
+import datetime
+from contextlib import ExitStack
+from decimal import Decimal
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.schema.data_types import PyarrowFieldParser
+from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+
+
+pytestmark = pytest.mark.native_plan
+
+_KEYS = [
+    (pa.int8(), pa.int64(), 1, 2),
+    (pa.int16(), pa.uint32(), 1, 2),
+    (pa.int32(), pa.int64(), 1, 2),
+    (pa.int64(), pa.uint64(), 1, 2),
+    (pa.decimal128(10, 2), pa.decimal128(15, 4), Decimal('1.25'), 
Decimal('-2.50')),
+    (pa.date32(), pa.date64(), datetime.date(1969, 12, 31), 
datetime.date(1970, 1, 2)),
+    (pa.time32('ms'), pa.time64('us'), datetime.time(1, 2, 3, 400000), 
datetime.time(4, 5, 6)),
+    (pa.timestamp('ms'), pa.timestamp('us'), datetime.datetime(1969, 12, 31, 
23, 59, 59, 999000),
+     datetime.datetime(1970, 1, 2)),
+    (pa.timestamp('us'), pa.timestamp('ms'), datetime.datetime(1969, 12, 31, 
23, 59, 59, 999000),
+     datetime.datetime(1970, 1, 2)),
+    (pa.timestamp('us', 'UTC'), pa.timestamp('ms', 'America/New_York'),
+     datetime.datetime(2024, 7, 1, tzinfo=datetime.timezone.utc),
+     datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)),
+    (pa.string(), pa.dictionary(pa.int8(), pa.string()), '中文', 'other'),
+    (pa.int32(), pa.dictionary(pa.int16(), pa.int64()), 1, 2),
+    (pa.binary(), pa.dictionary(pa.int8(), pa.binary()), b'key', b'other'),
+]
+
+
+def _array(values, data_type):
+    if pa.types.is_dictionary(data_type):
+        return pa.array(values, 
type=data_type.value_type).dictionary_encode().cast(data_type)
+    return pa.array(values, type=data_type)
+
+
+def _table(tmp_path, native, key_type, key, other, partitioned=False):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('key', key_type), ('pt', pa.string()), ('value', 
pa.int32()), ('keep', pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        schema, partition_keys=['pt'] if partitioned else None, options={
+            'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
+            'write.native.enabled': str(native).lower(),
+        }), False)
+    table = catalog.get_table('default.t')
+    schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.table({'key': [key, key, other, None], 'pt': 
['a', 'a', 'b', 'a'],
+                                    'value': [0] * 4, 'keep': [0, 1, 2, 3]}, 
schema=schema))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return table, schema
+
+
+def _upsert(table, source, native, stream, columns=('value',)):
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    with ExitStack() as stack:
+        if native:
+            stack.enter_context(patch.object(
+                TableUpsertByKey, '_upsert_partition', 
side_effect=AssertionError('Python key matching')))
+        update = builder.new_update()
+        if columns is not None:
+            update.with_update_type(list(columns))
+        messages = (update.upsert_by_arrow_with_key(source, ['key'], 37) if 
stream
+                    else update.upsert_by_arrow_with_key(source, ['key']))
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 37) if stream else commit.commit(messages)
+    finally:
+        commit.close()
+
+
+def _rows(table):
+    for native in (False, True):
+        view = table.copy({'read.native.enabled': str(native).lower(),
+                           'scan.native-plan.enabled': str(native).lower()})
+        builder = view.new_read_builder().with_projection(['key', 'pt', 
'value', 'keep', '_ROW_ID'])
+        rows = 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist()
+        yield sorted(rows, key=lambda row: row['keep'])
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('key_type,input_type,key,other', _KEYS)
+def test_upsert_normalizes_match_keys_without_changing_row_identity(
+        tmp_path, native, stream, key_type, input_type, key, other):
+    table, _ = _table(tmp_path, native, key_type, key, other)
+    before = next(_rows(table))
+    # Different dictionaries and the last duplicate occupy separate chunks.
+    chunks = [pa.table({'key': _array([value], input_type), 'value': 
pa.array([number], type=pa.int32())})
+              for value, number in [(key, 1), (None, 9), (key, 42)]]
+    _upsert(table, pa.concat_tables(chunks), native, stream)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [42, 42, 0, 9]
+        assert [{k: v for k, v in row.items() if k != 'value'} for row in 
rows] == [
+            {k: v for k, v in row.items() if k != 'value'} for row in before]
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
+def 
test_partition_key_dictionaries_and_integer_widths_share_one_identity(tmp_path, 
native, stream):
+    table, _ = _table(tmp_path, native, pa.int32(), 1, 2, partitioned=True)
+    source = pa.table({'key': pa.array([1, 2, 1], type=pa.int64()),
+                       'pt': _array(['a', 'b', 'a'], pa.dictionary(pa.int8(), 
pa.string())),
+                       'value': pa.array([1, 7, 42], type=pa.int32())})
+    before = next(_rows(table))
+    _upsert(table, source, native, stream)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [42, 42, 7, 0]
+        assert [row['_ROW_ID'] for row in rows] == [row['_ROW_ID'] for row in 
before]
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('key_type,input_type,key,other,new_key', [
+    (pa.int32(), pa.int64(), 1, 2, 3),
+    (pa.decimal128(10, 2), pa.decimal128(15, 4), Decimal('1.00'), 
Decimal('2.00'), Decimal('3.00')),
+    (pa.timestamp('ms'), pa.timestamp('us'), datetime.datetime(2024, 1, 1),
+     datetime.datetime(2024, 1, 2), datetime.datetime(2024, 1, 3)),
+])
+def test_new_rows_retain_original_key_write_type_validation(
+        tmp_path, native, key_type, input_type, key, other, new_key):
+    table, _ = _table(tmp_path, native, key_type, key, other)
+    before = next(_rows(table))
+    source = pa.table({'key': _array([new_key], input_type), 'value': 
pa.array([42], type=pa.int32())})
+    with pytest.raises((ValueError, TypeError, NotImplementedError)):
+        _upsert(table, source, native, False)
+    for rows in _rows(table):
+        assert rows == before
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('key_type,input_type,key,other,invalid', [
+    (pa.int8(), pa.int64(), 1, 2, 256),
+    (pa.int64(), pa.uint64(), 1, 2, 2 ** 63),
+    (pa.decimal128(10, 2), pa.decimal128(15, 4), Decimal('1.25'), 
Decimal('2.50'), Decimal('1.2501')),
+    (pa.timestamp('ms'), pa.timestamp('us'), datetime.datetime(2024, 1, 1),
+     datetime.datetime(2024, 1, 2), datetime.datetime(2024, 1, 1, 0, 0, 0, 1)),
+])
+def test_lossy_key_conversion_cannot_update_an_existing_row(
+        tmp_path, native, key_type, input_type, key, other, invalid):
+    table, _ = _table(tmp_path, native, key_type, key, other)
+    before = next(_rows(table))
+    source = pa.table({'key': _array([invalid], input_type), 'value': 
pa.array([42], type=pa.int32())})
+    with pytest.raises((ValueError, TypeError, NotImplementedError)):
+        _upsert(table, source, native, False)
+    for rows in _rows(table):
+        assert rows == before
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('key_type,key,other', [
+    (pa.int32(), 1, 2), (pa.string(), '中文', 'other'), (pa.binary(), b'key', 
b'other'),
+])
+def test_dictionary_keys_can_also_be_written_by_full_row_updates(tmp_path, 
native, stream, key_type, key, other):
+    table, _ = _table(tmp_path, native, key_type, key, other)
+    before = next(_rows(table))
+    source = pa.table({'key': _dictionary_array([key], key_type), 'pt': 
['updated'],
+                       'value': pa.array([42], type=pa.int32()), 'keep': 
pa.array([99], type=pa.int32())})
+    _upsert(table, source, native, stream, columns=None)
+    for rows in _rows(table):
+        by_id = {row['_ROW_ID']: row for row in rows}
+        for original in before:
+            expected = dict(original)
+            if original['key'] == key:
+                expected.update(pt='updated', value=42, keep=99)
+            assert by_id[original['_ROW_ID']] == expected
+
+
+def _dictionary_array(values, value_type):
+    return pa.array(values, type=value_type).dictionary_encode()
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
+def test_untyped_null_keys_match_only_existing_null_key_rows(tmp_path, native):
+    table, _ = _table(tmp_path, native, pa.int32(), 1, 2)
+    source = pa.table({'key': pa.nulls(1), 'value': pa.array([9], 
type=pa.int32())})
+    _upsert(table, source, native, False)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [0, 0, 0, 9]
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
+def 
test_dictionary_null_values_and_null_indices_share_upsert_identity(tmp_path, 
native, stream):
+    table, _ = _table(tmp_path, native, pa.string(), 'key', 'other')
+    keys = pa.DictionaryArray.from_arrays(pa.array([0, 1, None], 
type=pa.int8()), pa.array([None, 'key']))
+    source = pa.table({'key': keys, 'value': pa.array([9, 42, 11], 
type=pa.int32())})
+    _upsert(table, source, native, stream)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [42, 42, 0, 11]
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
+def 
test_decimal_zero_and_null_keys_rescale_across_the_full_scale_range(tmp_path, 
native, stream):
+    table, _ = _table(tmp_path, native, pa.decimal128(38, 38), Decimal('0'), 
Decimal('1e-38'))
+    source = pa.table({'key': pa.array([Decimal('0'), None], 
type=pa.decimal128(38, -2)),
+                       'value': pa.array([42, 9], type=pa.int32())})
+    _upsert(table, source, native, stream)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [42, 42, 0, 9]
diff --git a/paimon-python/pypaimon/tests/native_upsert_selected_values_test.py 
b/paimon-python/pypaimon/tests/native_upsert_selected_values_test.py
new file mode 100644
index 0000000000..9a6f6df9a6
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_upsert_selected_values_test.py
@@ -0,0 +1,283 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Upsert converts only values selected by matching and last-write-wins."""
+
+import datetime
+from contextlib import ExitStack
+from decimal import Decimal
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.schema.data_types import PyarrowFieldParser
+from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+
+
+pytestmark = pytest.mark.native_plan
+
+
+def _table(tmp_path, native, value_type=pa.int32()):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('key', pa.int32()), ('pt', pa.string()),
+                        ('value', value_type), ('keep', pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
partition_keys=['pt'], options={
+        'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
+        'write.native.enabled': str(native).lower(),
+    }), False)
+    table = catalog.get_table('default.t')
+    return table, PyarrowFieldParser.from_paimon_schema(table.fields)
+
+
+def _seed(table, schema, value):
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    commit = builder.new_commit()
+    try:
+        writer.write_arrow(pa.table({'key': [1, 1, 1], 'pt': ['a', 'a', 'b'],
+                                    'value': [value] * 3, 'keep': [10, 11, 
12]}, schema=schema))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    read = table.new_read_builder().with_projection(['keep', '_ROW_ID'])
+    rows = 
read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist()
+    return [row['_ROW_ID'] for row in sorted(rows, key=lambda row: 
row['keep'])]
+
+
+def _upsert(table, source, native, stream, columns=('value',)):
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    with ExitStack() as stack:
+        if native:
+            stack.enter_context(patch.object(
+                TableUpsertByKey, '_upsert_partition',
+                side_effect=AssertionError('upsert fell back to Python')))
+        update = builder.new_update().with_update_type(list(columns))
+        messages = (update.upsert_by_arrow_with_key(source, ['key'], 17) if 
stream
+                    else update.upsert_by_arrow_with_key(source, ['key']))
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 17) if stream else commit.commit(messages)
+    finally:
+        commit.close()
+    return messages
+
+
+def _rows(table, native_modes=(False, True)):
+    for native in native_modes:
+        view = table.copy({'read.native.enabled': str(native).lower(),
+                           'scan.native-plan.enabled': str(native).lower()})
+        builder = view.new_read_builder().with_projection(['key', 'pt', 
'value', 'keep', '_ROW_ID'])
+        rows = 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist()
+        yield sorted(rows, key=lambda row: row['keep'])
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
[email protected]('value_type,input_type,initial,value,expected', [
+    (pa.int32(), pa.string(), 0, '42', 42),
+    (pa.int32(), pa.float64(), 0, -42.9, -42),
+    (pa.int64(), pa.int32(), 0, 42, 42),
+    (pa.int32(), pa.null(), 0, None, None),
+    (pa.decimal128(8, 2), pa.string(), Decimal('0.00'), '42.25', 
Decimal('42.25')),
+    (pa.list_(pa.int32()), pa.list_(pa.string()), [], ['42', None, '-7'], [42, 
None, -7]),
+    (pa.struct([('n', pa.int32())]), pa.struct([('n', pa.string())]),
+     {'n': 0}, {'n': '42'}, {'n': 42}),
+    (pa.binary(), pa.string(), b'old', '中文', '中文'.encode()),
+    (pa.binary(), pa.null(), b'old', None, None),
+    (pa.large_binary(), pa.string(), b'old', 'new', b'new'),
+    (pa.large_binary(), pa.null(), b'old', None, None),
+    (pa.int32(), pa.binary(), 0, b'42', 42),
+    (pa.list_(pa.int32()), pa.list_(pa.binary()), [], [b'42', None], [42, 
None]),
+    (pa.struct([('data', pa.binary()), ('n', pa.int32())]),
+     pa.struct([('data', pa.binary()), ('n', pa.string())]),
+     {'data': b'old', 'n': 0}, {'data': b'new', 'n': '42'}, {'data': b'new', 
'n': 42}),
+    (pa.struct([('data', pa.binary()), ('n', pa.string())]),
+     pa.struct([('data', pa.binary()), ('n', pa.int32())]),
+     {'data': b'old', 'n': 'old'}, {'data': b'new', 'n': 42}, {'data': b'new', 
'n': '42'}),
+    (pa.map_(pa.string(), pa.binary()), pa.struct([('a', pa.string())]),
+     [('a', b'old')], {'a': 'new'}, [('a', b'new')]),
+    (pa.map_(pa.string(), pa.large_binary()), pa.map_(pa.string(), 
pa.string()),
+     [('a', b'old')], [('a', 'new')], [('a', b'new')]),
+    (pa.list_(pa.int32()), pa.struct([('dummy', pa.int32())]), [], None, None),
+])
+def test_matched_values_use_row_id_update_casts(
+        tmp_path, native, stream, value_type, input_type, initial, value, 
expected):
+    table, schema = _table(tmp_path, native, value_type)
+    row_ids = _seed(table, schema, initial)
+    source = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'],
+                       'value': pa.array([value], type=input_type)})
+    _upsert(table, source, native, stream)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [expected, expected, initial]
+        assert [row['keep'] for row in rows] == [10, 11, 12]
+        assert [row['_ROW_ID'] for row in rows] == row_ids
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
+def test_matched_rows_ignore_unwritten_payload_columns(tmp_path, native, 
stream):
+    table, schema = _table(tmp_path, native)
+    _seed(table, schema, 0)
+    source = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'],
+                       'value': pa.array([42], type=pa.int32()), 'keep': ['not 
an integer']})
+    _upsert(table, source, native, stream)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [42, 42, 0]
+        assert [row['keep'] for row in rows] == [10, 11, 12]
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
[email protected]('encoded_payload', [False, True], ids=['map', 
'dictionary-map'])
[email protected]('target_key,input_key,initial,value,expected', [
+    (pa.int32(), pa.int64(), 1, 2, 2),
+    (pa.string(), pa.dictionary(pa.int8(), pa.string()), 'old', 'new', 'new'),
+    (pa.decimal128(10, 2), pa.decimal128(8, 3), Decimal('1.00'), 
Decimal('1.235'), Decimal('1.24')),
+    (pa.time32('ms'), pa.time64('us'), datetime.time(1), datetime.time(1, 2, 
3, 123456),
+     datetime.time(1, 2, 3, 123000)),
+])
+def test_matched_blob_map_keys_follow_java_serialization(
+        tmp_path, native, stream, encoded_payload, target_key, input_key, 
initial, value, expected):
+    table, schema = _table(tmp_path, native, pa.map_(target_key, 
pa.large_binary()))
+    row_ids = _seed(table, schema, [(initial, b'old')])
+    keys = (pa.array([value], type=input_key.value_type).dictionary_encode()
+            if pa.types.is_dictionary(input_key) else pa.array([value], 
type=input_key))
+    payload = pa.MapArray.from_arrays(
+        pa.array([0, 1], type=pa.int32()), keys, pa.array([b'new'], 
type=pa.large_binary()))
+    if encoded_payload:
+        payload = pa.DictionaryArray.from_arrays(pa.array([0], 
type=pa.int8()), payload)
+    source = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'], 
'value': payload})
+    _upsert(table, source, native, stream)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [[(expected, b'new')]] * 2 + 
[[(initial, b'old')]]
+        assert [row['_ROW_ID'] for row in rows] == row_ids
+    # An unmatched outer key is an ordinary append, whose Arrow types remain 
strict.
+    source = source.set_column(0, 'key', pa.array([2], type=pa.int32()))
+    with pytest.raises((ValueError, TypeError, NotImplementedError)):
+        _upsert(table, source, native, stream)
+    for rows in _rows(table):
+        assert [row['_ROW_ID'] for row in rows] == row_ids
+
+
[email protected]('native', [False, True])
[email protected]('key_type,input_type,initial,values', [
+    (pa.time32('ms'), pa.time64('us'), datetime.time(1),
+     [datetime.time(1, 2, 3, 123001), datetime.time(1, 2, 3, 123999)]),
+    (pa.decimal128(10, 2), pa.decimal128(8, 3), Decimal('1.00'), 
[Decimal('1.231'), Decimal('1.234')]),
+])
+def test_blob_map_duplicate_keys_are_rejected_after_java_normalization(
+        tmp_path, native, key_type, input_type, initial, values):
+    table, schema = _table(tmp_path, native, pa.map_(key_type, 
pa.large_binary()))
+    row_ids = _seed(table, schema, [(initial, b'old')])
+    source = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'],
+                       'value': pa.array([[(value, b'new') for value in 
values]],
+                                         type=pa.map_(input_type, 
pa.large_binary()))})
+    with pytest.raises(ValueError, match='keys must be unique'):
+        _upsert(table, source, native, False)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [[(initial, b'old')]] * 3
+        assert [row['_ROW_ID'] for row in rows] == row_ids
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
+def test_discarded_duplicate_values_are_not_cast(tmp_path, native, stream):
+    table, schema = _table(tmp_path, native)
+    _seed(table, schema, 0)
+    first = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'], 
'value': ['invalid']})
+    last = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'], 
'value': ['42']})
+    # The winning key is in a separate source chunk.
+    _upsert(table, pa.concat_tables([first, last]), native, stream)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [42, 42, 0]
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
+def 
test_discarded_duplicates_do_not_validate_required_nested_children(tmp_path, 
native, stream):
+    value_type = pa.struct([pa.field('n', pa.int32(), nullable=False), 
pa.field('label', pa.string())])
+    table, schema = _table(tmp_path, native, value_type)
+    _seed(table, schema, {'n': 0, 'label': 'old'})
+    source = pa.table({'key': pa.array([1, 1], type=pa.int32()), 'pt': ['a', 
'a'],
+                       'value': pa.array([{'n': None, 'label': 'discarded'}, 
{'n': 42, 'label': 'final'}],
+                                         type=pa.struct([('n', pa.int32()), 
('label', pa.string())]))})
+    _upsert(table, source, native, stream)
+    # Python's format reader cannot restore required ROW child metadata. Read
+    # the files from both update implementations through the native reader.
+    for rows in _rows(table, native_modes=(True,)):
+        assert [row['value'] for row in rows] == [
+            {'n': 42, 'label': 'final'}, {'n': 42, 'label': 'final'}, {'n': 0, 
'label': 'old'}]
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
+def test_invalid_surviving_update_value_propagates_without_retry(tmp_path, 
native, stream):
+    table, schema = _table(tmp_path, native)
+    _seed(table, schema, 0)
+    source = pa.table({'key': pa.array([1, 1], type=pa.int32()), 'pt': ['a', 
'a'],
+                       'value': ['42', 'invalid']})
+    with pytest.raises((ValueError, TypeError)):
+        _upsert(table, source, native, stream)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [0, 0, 0]
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
+def test_new_rows_keep_ordinary_write_type_validation(tmp_path, native, 
stream):
+    table, schema = _table(tmp_path, native)
+    row_ids = _seed(table, schema, 0)
+    source = pa.table({'key': pa.array([2], type=pa.int32()), 'pt': ['a'], 
'value': ['42']})
+    with pytest.raises((ValueError, TypeError, NotImplementedError)):
+        _upsert(table, source, native, stream)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [0, 0, 0]
+        assert [row['_ROW_ID'] for row in rows] == row_ids
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
[email protected]('target_type,source_type,initial,value,expected', [
+    (pa.binary(), pa.large_binary(), b'old', b'\xff\0new', b'\xff\0new'),
+    (pa.large_binary(), pa.binary(), b'old', b'\xff\0new', b'\xff\0new'),
+    (pa.binary(), pa.large_binary(), b'old', None, None),
+    (pa.large_binary(), pa.binary(), b'old', None, None),
+    (pa.map_(pa.string(), pa.binary()),
+     pa.map_(pa.field('k', pa.string(), nullable=False), pa.field('v', 
pa.large_binary())),
+     [('a', b'old')], [('a', b'new')], [('a', b'new')]),
+    (pa.map_(pa.string(), pa.binary()), pa.struct([('a', pa.large_binary())]),
+     [('a', b'old')], {'a': b'new'}, [('a', b'new')]),
+    (pa.struct([('a', pa.binary())]), pa.map_(pa.string(), pa.large_binary()),
+     {'a': b'old'}, [('a', b'new')], {'a': b'new'}),
+    (pa.map_(pa.string(), pa.binary()),
+     pa.list_(pa.list_(pa.large_binary())),
+     [('a', b'old')], [[b'a', b'new']], [('a', b'new')]),
+])
+def test_matched_binary_values_use_existing_row_id_casts(
+        tmp_path, native, stream, target_type, source_type, initial, value, 
expected):
+    table, schema = _table(tmp_path, native, target_type)
+    row_ids = _seed(table, schema, initial)
+    source = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'],
+                       'value': pa.array([value], type=source_type)})
+    _upsert(table, source, native, stream)
+    for rows in _rows(table):
+        assert [row['value'] for row in rows] == [expected, expected, initial]
+        assert [row['_ROW_ID'] for row in rows] == row_ids
diff --git a/paimon-python/pypaimon/write/native_merge_into.py 
b/paimon-python/pypaimon/write/native_merge_into.py
index 7999c00d72..331804fc6c 100644
--- a/paimon-python/pypaimon/write/native_merge_into.py
+++ b/paimon-python/pypaimon/write/native_merge_into.py
@@ -24,7 +24,8 @@ from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
 from pypaimon.write.native_commit import create_native_write_table, 
from_native_commit_messages
 from pypaimon.write.native_update import (
-    _native_row_id_table, _native_update_columns_supported, 
_supported_upsert_key_type,
+    _dictionary_value_type, _native_row_id_table, 
_native_update_columns_supported,
+    _native_update_formats_supported, _supported_upsert_key_type,
 )
 from pypaimon.write.native_write import _native_partition_types_supported
 
@@ -49,8 +50,8 @@ def create_native_merge_into(table, source, on, matched, 
not_matched, commit_use
         from pypaimon.read.native_plan import _native_blob_view_supported
         from pypaimon.table.file_store_table import FileStoreTable
         if (type(source) is not FileStoreTable
-                or source.options.file_format() != 'parquet'
-                or source.options.video_frame_fields() or 
source.options.with_vector_format()
+                or not _native_update_formats_supported(source)
+                or source.options.video_frame_fields()
                 or (source.is_primary_key_table and not 
source.trimmed_primary_keys)):
             return None
         from pypaimon.read.table_scan import _validate_scan_mode
@@ -79,7 +80,7 @@ def create_native_merge_into(table, source, on, matched, 
not_matched, commit_use
         return None
     for target, origin in zip(target_keys, source_keys):
         target_type = pa.int64() if target == '_ROW_ID' else 
context.full_pa_schema.field(target).type
-        source_type = target_type if self_merge else 
source.schema.field(origin).type
+        source_type = target_type if self_merge else 
_dictionary_value_type(source.schema.field(origin).type)
         if source_type != target_type or not 
_supported_upsert_key_type(target_type):
             return None
 
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index 05285adfe8..dbf0b50d17 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -35,12 +35,16 @@ from pypaimon.write.table_update_by_row_id import 
TableUpdateByRowId
 from pypaimon.write.row_utils import _contains_blob_value, value_for_arrow
 
 
+def _native_update_formats_supported(table):
+    return (table.options.file_format() == 'parquet'
+            and (not table.options.with_vector_format() or 
table.options.vector_file_format() == 'parquet'))
+
+
 def _native_update_table(table):
     """Resolve common native update prerequisites before preparing changes."""
     if (type(table) is not FileStoreTable
             or not table.options.native_write_enabled()
-            or table.options.file_format() != 'parquet'
-            or table.options.with_vector_format()
+            or not _native_update_formats_supported(table)
             or table.options.changelog_producer() != ChangelogProducer.NONE
             or any(table.options.options.contains_key(key) for key in 
SCAN_KEYS)
             or not native_write_available()):
@@ -154,6 +158,27 @@ def _supported_upsert_key_type(data_type):
     ))
 
 
+def _dictionary_value_type(data_type):
+    while pa.types.is_dictionary(data_type):
+        data_type = data_type.value_type
+    return data_type
+
+
+def _native_upsert_key_type_compatible(source, target):
+    """Qualify layouts; exact values and conversion bounds belong to core."""
+    source = _dictionary_value_type(source)
+    if pa.types.is_null(source):
+        return True
+    for family in (pa.types.is_integer, pa.types.is_decimal, pa.types.is_date, 
pa.types.is_time):
+        if family(source) and family(target):
+            return True
+    if pa.types.is_timestamp(source) and pa.types.is_timestamp(target):
+        return (source.tz is None) == (target.tz is None)
+    return arrow_schemas_compatible(
+        pa.schema([pa.field('key', source)]), pa.schema([pa.field('key', 
target)]),
+        check_top_level_nullability=False, allow_binary_compatibility=True)
+
+
 def _native_update_columns_supported(table, columns):
     """Core writes Parquet columns and Java-compatible sparse Blob deltas."""
     if type(table) is not FileStoreTable:
@@ -217,12 +242,12 @@ def create_native_upsert(table, commit_user, data, keys, 
columns):
         if (len(data.column_names) != len(set(data.column_names))
                 or not set(data.column_names) <= set(schema.names)):
             return None
-        # Core normalizes equivalent Arrow layouts before key encoding and
-        # writing. Keep value conversions on the existing Python path.
-        supplied = pa.schema([schema.field(name) for name in 
data.column_names])
-        if not arrow_schemas_compatible(
-                data.schema, supplied, check_top_level_nullability=False,
-                allow_binary_compatibility=True, 
check_nested_nullability=False):
+        # Keys need a shared representation before matching. Core selects
+        # surviving payloads before applying row-ID update or append rules;
+        # validating all payload types here would reject valid matched updates.
+        key_names = list(dict.fromkeys(keys + table.partition_keys))
+        if not 
all(_native_upsert_key_type_compatible(data.schema.field(name).type, 
schema.field(name).type)
+                   for name in key_names):
             return None
     writer = (native_table.new_batch_write_builder()
               ._with_commit_user(commit_user)
diff --git a/paimon-python/pypaimon/write/table_update.py 
b/paimon-python/pypaimon/write/table_update.py
index 82e5fc7fc1..32a4562afb 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -957,7 +957,9 @@ class ShardTableUpdator:
     def arrow_reader(self) -> pyarrow.ipc.RecordBatchReader:
         read_builder = self.table.new_read_builder()
         read_builder.with_projection(self.projection)
-        return read_builder.new_read().to_arrow_batch_reader(self.splits)
+        # Updates consume row ranges in split order. Parallel native readers
+        # return batches in completion order, which would assign values to 
other rows.
+        return read_builder.new_read().to_arrow_batch_reader(self.splits, 
parallelism=1)
 
     def prepare_commit(self) -> List[CommitMessage]:
         commit_messages = []

Reply via email to