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 e42cd13023 Use Rust core for native Arrow upserts and table updates 
(#10185)
e42cd13023 is described below

commit e42cd130237d9a3fd1fb4dee1fce93701b048eea
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Sep 26 21:59:39 2026 +0800

    Use Rust core for native Arrow upserts and table updates (#10185)
---
 .github/workflows/ci-python.yml                    |    7 +-
 paimon-python/conftest.py                          |   43 +-
 .../pypaimon/ray/data_evolution_merge_join.py      |   10 +-
 .../pypaimon/tests/deletion_vector_path_test.py    |    5 +-
 paimon-python/pypaimon/tests/native_update_test.py | 1024 ++++++++++++++++++++
 paimon-python/pypaimon/tests/table_update_test.py  |    3 +
 paimon-python/pypaimon/write/native_commit.py      |   16 +-
 paimon-python/pypaimon/write/native_update.py      |  304 ++++++
 paimon-python/pypaimon/write/native_write.py       |   27 +-
 paimon-python/pypaimon/write/table_update.py       |   73 +-
 .../pypaimon/write/table_update_by_row_id.py       |    3 +
 .../pypaimon/write/table_upsert_by_key.py          |   23 +
 12 files changed, 1511 insertions(+), 27 deletions(-)

diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml
index 6c756f578e..267e4fd0da 100644
--- a/.github/workflows/ci-python.yml
+++ b/.github/workflows/ci-python.yml
@@ -196,10 +196,10 @@ jobs:
           curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- 
-y --default-toolchain stable --profile minimal
           echo "$HOME/.cargo/bin" >> $GITHUB_PATH
 
-      - name: Build paimon-rust with REST native commit support
+      - name: Build paimon-rust with native update support
         run: |
           python -m pip install --upgrade pip
-          python -m pip install --upgrade 
"git+https://github.com/apache/paimon-rust.git@aa60a6ac7cbe9299aca19effd9ba8301c1bc55da#subdirectory=bindings/python";
+          python -m pip install --upgrade 
"git+https://github.com/apache/paimon-rust.git@780bc7957f24a78828b7910369e079bbff30e92c#subdirectory=bindings/python";
 
       - name: Install Python test dependencies
         run: |
@@ -227,12 +227,13 @@ jobs:
                 'commit', source['vcs_info']['commit_id'])
           PY
 
-      - name: Run Python tests with native planning, reading, writing and 
committing
+      - name: Run Python tests with native planning, reading, writing, 
updating and committing
         shell: bash
         env:
           PYPAIMON_TEST_NATIVE_PLAN: '1'
           PYPAIMON_TEST_NATIVE_READ: '1'
           PYPAIMON_TEST_NATIVE_WRITE: '1'
+          PYPAIMON_TEST_NATIVE_UPDATE: '1'
           PYPAIMON_TEST_NATIVE_COMMIT: '1'
         run: |
           bash paimon-python/dev/lint-python.sh -i pytest
diff --git a/paimon-python/conftest.py b/paimon-python/conftest.py
index 272428f9e9..ef2e3b5ec1 100644
--- a/paimon-python/conftest.py
+++ b/paimon-python/conftest.py
@@ -22,15 +22,18 @@ import pytest
 _NATIVE_PLAN_ENV = "PYPAIMON_TEST_NATIVE_PLAN"
 _NATIVE_READ_ENV = "PYPAIMON_TEST_NATIVE_READ"
 _NATIVE_WRITE_ENV = "PYPAIMON_TEST_NATIVE_WRITE"
+_NATIVE_UPDATE_ENV = "PYPAIMON_TEST_NATIVE_UPDATE"
 _NATIVE_COMMIT_ENV = "PYPAIMON_TEST_NATIVE_COMMIT"
 _native_plan_count = 0
 _native_read_count = 0
 _native_write_count = 0
 _native_commit_count = 0
+_native_update_counts = dict.fromkeys(('row_id', 'grouped', 'predicate', 
'upsert', 'incremental'), 0)
 _force_native_for_test = False
 _force_native_read_for_test = False
 _force_native_write_for_test = False
 _force_native_commit_for_test = False
+_force_native_update_for_test = False
 
 
 def pytest_addoption(parser):
@@ -81,6 +84,10 @@ def _native_commit_enabled():
     return os.environ.get(_NATIVE_COMMIT_ENV) == "1"
 
 
+def _native_update_enabled():
+    return os.environ.get(_NATIVE_UPDATE_ENV) == "1"
+
+
 def pytest_configure(config):
     config.addinivalue_line(
         "markers", "python_plan: keep Python planner assertions on the Python 
lane")
@@ -149,6 +156,29 @@ def pytest_configure(config):
 
         TableCommit._prepare_native_commit = tracked_prepare
 
+    if _native_update_enabled():
+        from pypaimon.write.native_update import (
+            NativeBatchTableUpdate, NativePredicateTableUpdate,
+            NativeTableUpdateByRowId, NativeTableUpsert,
+        )
+
+        def track_update(cls, method, kind):
+            original = getattr(cls, method)
+
+            def tracked(self, *args, **kwargs):
+                messages = original(self, *args, **kwargs)
+                if messages and _force_native_update_for_test:
+                    _native_update_counts[kind] += 1
+                return messages
+
+            setattr(cls, method, tracked)
+
+        track_update(NativeBatchTableUpdate, 'update_by_arrow_with_row_id', 
'row_id')
+        track_update(NativeBatchTableUpdate, 
'update_by_arrow_batches_with_row_id', 'grouped')
+        track_update(NativePredicateTableUpdate, 'update', 'predicate')
+        track_update(NativeTableUpsert, 'upsert', 'upsert')
+        track_update(NativeTableUpdateByRowId, 'update_columns', 'incremental')
+
 
 def pytest_collection_modifyitems(items):
     if _native_plan_enabled():
@@ -163,6 +193,7 @@ def pytest_collection_modifyitems(items):
 def enable_native_backends(request, monkeypatch):
     global _force_native_for_test, _force_native_read_for_test
     global _force_native_write_for_test, _force_native_commit_for_test
+    global _force_native_update_for_test
     python_plan = request.node.get_closest_marker("python_plan") is not None
     python_read = request.node.get_closest_marker("python_read") is not None
     python_write = request.node.get_closest_marker("python_write") is not None
@@ -175,7 +206,8 @@ def enable_native_backends(request, monkeypatch):
                   and not native_plan_test)
     force_write = _native_write_enabled() and not python_write
     force_commit = _native_commit_enabled() and not python_commit
-    if not (force_plan or force_read or force_write or force_commit):
+    force_update = _native_update_enabled() and not python_write
+    if not (force_plan or force_read or force_write or force_commit or 
force_update):
         yield
         return
 
@@ -195,7 +227,7 @@ def enable_native_backends(request, monkeypatch):
             return original_read(self, True if default is None else default)
 
         monkeypatch.setattr(CoreOptions, "native_read_enabled", read_enabled)
-    if force_write:
+    if force_write or force_update:
         original_write = CoreOptions.native_write_enabled
 
         def write_enabled(self, default=None):
@@ -213,6 +245,7 @@ def enable_native_backends(request, monkeypatch):
     _force_native_read_for_test = force_read
     _force_native_write_for_test = force_write
     _force_native_commit_for_test = force_commit
+    _force_native_update_for_test = force_update
     try:
         yield
     finally:
@@ -220,6 +253,7 @@ def enable_native_backends(request, monkeypatch):
         _force_native_read_for_test = False
         _force_native_write_for_test = False
         _force_native_commit_for_test = False
+        _force_native_update_for_test = False
 
 
 def pytest_sessionfinish(session, exitstatus):
@@ -227,6 +261,7 @@ def pytest_sessionfinish(session, exitstatus):
         if ((_native_plan_enabled() and _native_plan_count == 0)
                 or (_native_read_enabled() and _native_read_count == 0)
                 or (_native_write_enabled() and _native_write_count == 0)
+                or (_native_update_enabled() and not 
all(_native_update_counts.values()))
                 or (_native_commit_enabled() and _native_commit_count == 0)):
             session.exitstatus = pytest.ExitCode.TESTS_FAILED
 
@@ -244,3 +279,7 @@ def pytest_terminal_summary(terminalreporter):
     if _native_commit_enabled():
         terminalreporter.write_line(
             "native commits exercised: %d" % _native_commit_count)
+    if _native_update_enabled():
+        terminalreporter.write_line(
+            "native updates exercised: " + ', '.join(
+                '%s=%d' % item for item in _native_update_counts.items()))
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index b86ac7b363..3cccc3dafb 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -715,7 +715,8 @@ def distributed_update_apply(
         table.copy({CoreOptions.SCAN_SNAPSHOT_ID.key(): str(base_snapshot_id)})
         if base_snapshot_id is not None else table
     )
-    planner = 
scan_table.new_batch_write_builder().new_update().new_update_by_row_id()
+    from pypaimon.write.row_id_file_index import RowIdFileIndex
+    planner = RowIdFileIndex.from_table(scan_table)
     sorted_first_row_ids = list(planner.first_row_ids)
     if not sorted_first_row_ids:
         return [], 0, []
@@ -742,7 +743,7 @@ def distributed_update_apply(
     # detection covers the read→planner window.
     from dataclasses import replace
     files_info = replace(
-        planner._snapshot_files_info(),
+        planner,
         snapshot_id=check_from_snapshot,
     )
     precomputed_info_ref = ray.put(files_info)
@@ -916,7 +917,8 @@ def distributed_read_by_row_id(
     empty_out = _read_output_schema(table, read_cols).empty_table()
 
     # The caller pinned the resolved snapshot, including any retained tag 
metadata.
-    planner = 
table.new_batch_write_builder().new_update().new_update_by_row_id()
+    from pypaimon.write.row_id_file_index import RowIdFileIndex
+    planner = RowIdFileIndex.from_table(table)
     sorted_first_row_ids = list(planner.first_row_ids)
     if not sorted_first_row_ids:
         return None
@@ -929,7 +931,7 @@ def distributed_read_by_row_id(
         data_context=data_context,
     )
 
-    precomputed_info_ref = ray.put(planner._snapshot_files_info())
+    precomputed_info_ref = ray.put(planner)
     frid_col = "_FIRST_ROW_ID"
     sorted_arr = np.asarray(sorted_first_row_ids, dtype=np.int64)
     valid_ranges = planner.valid_row_id_ranges
diff --git a/paimon-python/pypaimon/tests/deletion_vector_path_test.py 
b/paimon-python/pypaimon/tests/deletion_vector_path_test.py
index b1d9d4ce72..23db62775f 100644
--- a/paimon-python/pypaimon/tests/deletion_vector_path_test.py
+++ b/paimon-python/pypaimon/tests/deletion_vector_path_test.py
@@ -151,8 +151,9 @@ def 
test_legacy_python_index_directory_remains_readable_and_new_deletes_use_buck
     # Old Python writers ignored the option and placed the index under 
table/index.
     legacy_factory = table.path_factory()
     legacy_factory.index_file_in_data_file_dir = False
-    with patch.object(table, 'path_factory', return_value=legacy_factory):
-        _delete(table, [0, 2])
+    legacy_table = table.copy({'write.native.enabled': 'false'})
+    with patch.object(legacy_table, 'path_factory', 
return_value=legacy_factory):
+        _delete(legacy_table, [0, 2])
     old_paths = [Path(table.table_path) / 'index' / entry.index_file.file_name 
for entry in _entries(table, 2)]
     assert all(path.is_file() for path in old_paths)
     _read(table, planner, 2, [1, 3])
diff --git a/paimon-python/pypaimon/tests/native_update_test.py 
b/paimon-python/pypaimon/tests/native_update_test.py
new file mode 100644
index 0000000000..1c01054aef
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_update_test.py
@@ -0,0 +1,1024 @@
+# 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.
+
+"""End-to-end coverage of the optional native batch row-ID update bridge."""
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.write.native_update import create_native_delete, 
create_native_update
+from pypaimon.write.table_delete import TableDeleteByRowId
+from pypaimon.read.table_read import TableRead
+from pypaimon.write.table_update import BatchTableUpdate, StreamTableUpdate
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+
+
+pytestmark = pytest.mark.native_plan
+
+
[email protected]('stream', [False, True])
+def 
test_incremental_row_id_updater_uses_core_and_accumulates_columns(tmp_path, 
stream):
+    from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
+    from pypaimon.table.row.generic_row import GenericRow
+    from pypaimon.write.native_update import NativeTableUpdateByRowId
+
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('name', pa.string()), ('age', 
pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'write.native.enabled': 'true',
+    }), False)
+    table = catalog.get_table('default.t')
+    seed = table.new_batch_write_builder()
+    writer = seed.new_write()
+    writer.write_arrow(pa.table({'id': [1, 2], 'name': ['a', 'b'], 'age': [10, 
20]}, schema=schema))
+    seed.new_commit().commit(writer.prepare_commit())
+    writer.close()
+    snapshot_id = table.snapshot_manager().get_latest_snapshot().id
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    commit_id = 27 if stream else BATCH_COMMIT_IDENTIFIER
+    # Factory configuration must not fix columns for this incremental API.
+    update = builder.new_update().with_update_type(['id'])
+    with patch.object(TableUpdateByRowId, '_load_existing_files_info',
+                      side_effect=AssertionError('Python loaded the file 
index')):
+        updater = update.new_update_by_row_id(commit_id) if stream else 
update.new_update_by_row_id()
+        assert isinstance(updater, NativeTableUpdateByRowId)
+        assert updater.commit_user == builder.commit_user
+        assert updater.commit_identifier == commit_id
+        assert updater.commit_messages == []
+        first = updater.update_columns(pa.table({'_ROW_ID': [0], 'age': 
[11]}), ['age'])
+        messages = updater.update_row_columns(
+            GenericRow(['B'], fields=[table.field_dict['name']]), [1], 
['name'])
+        assert len(first) == 1
+        assert len(messages) == len(updater.commit_messages) == 2
+        assert all(message.check_from_snapshot == snapshot_id for message in 
messages)
+        staged = set(tmp_path.rglob('*.parquet'))
+        with pytest.raises(ValueError, match='overlapping first_row_ids'):
+            updater.update_columns(pa.table({'_ROW_ID': [1], 'age': [99]}), 
['age'])
+        assert set(tmp_path.rglob('*.parquet')) == staged
+        assert len(updater.commit_messages) == 2
+    commit = builder.new_commit()
+    if stream:
+        commit.commit(updater.commit_messages, commit_id)
+    else:
+        commit.commit(updater.commit_messages)
+    commit.close()
+    assert table.snapshot_manager().get_latest_snapshot().commit_identifier == 
commit_id
+    read = table.new_read_builder()
+    actual = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
+    assert actual.to_pydict() == {'id': [1, 2], 'name': ['a', 'B'], 'age': 
[11, 20]}
+
+
[email protected]('stream', [False, True])
+def test_partitioned_upsert_and_incremental_update_use_core(tmp_path, stream):
+    from pypaimon.write.native_update import NativeTableUpdateByRowId
+    from pypaimon.table.row.generic_row import GenericRow
+
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('p', pa.string()), ('id', pa.int32()), ('v', 
pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
partition_keys=['p'], options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'write.native.enabled': 'true',
+    }), False)
+    table = catalog.get_table('default.t')
+    seed = table.new_batch_write_builder()
+    writer = seed.new_write()
+    writer.write_arrow(pa.table({'p': ['a', 'b', None], 'id': [1, 1, 1], 'v': 
[10, 20, 30]}, schema=schema))
+    seed.new_commit().commit(writer.prepare_commit())
+    writer.close()
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update()
+    input_rows = pa.table({'p': ['a', 'c', None], 'id': [1, 1, 1], 'v': [11, 
40, 31]}, schema=schema)
+    with patch.object(TableUpsertByKey, '_upsert_partition',
+                      side_effect=AssertionError('Python partition upsert 
selected')):
+        messages = (update.upsert_by_arrow_with_key(input_rows, ['id'], 1) if 
stream
+                    else update.upsert_by_arrow_with_key(input_rows, ['id']))
+    commit = builder.new_commit()
+    commit.commit(messages, 1) if stream else commit.commit(messages)
+    commit.close()
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update()
+    with patch.object(TableUpsertByKey, '_upsert_row_partition',
+                      side_effect=AssertionError('Python partition row upsert 
selected')):
+        rows = [GenericRow(['a', 1, 12], table.fields)]
+        messages = (update.upsert_by_key(rows, ['id'], 2) if stream
+                    else update.upsert_by_key(rows, ['id']))
+    commit = builder.new_commit()
+    commit.commit(messages, 2) if stream else commit.commit(messages)
+    commit.close()
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update()
+    read = table.new_read_builder().with_projection(['p', 'id', 'v', 
'_ROW_ID'])
+    current = read.new_read().to_arrow(read.new_scan().plan().splits())
+    selected = current.filter(pa.compute.equal(current['p'], 'a'))
+    updater = update.new_update_by_row_id(3) if stream else 
update.new_update_by_row_id()
+    assert isinstance(updater, NativeTableUpdateByRowId)
+    messages = updater.update_columns(selected.select(['p', '_ROW_ID']), ['p'])
+    commit = builder.new_commit()
+    commit.commit(messages, 3) if stream else commit.commit(messages)
+    commit.close()
+    read = table.new_read_builder()
+    actual = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('p')
+    assert actual.to_pydict() == {'p': ['a', 'b', 'c', None], 'id': [1, 1, 1, 
1], 'v': [12, 20, 40, 31]}
+
+
+def test_batch_row_id_update_uses_rust_and_python_commit(tmp_path):
+
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([
+        ('id', pa.int32()), ('name', pa.string()), ('age', pa.int32()),
+    ])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true',
+        'data-evolution.enabled': 'true',
+        'write.native.enabled': 'true',
+    }), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.Table.from_pydict({
+        'id': [1, 2, 3], 'name': ['a', 'b', 'c'], 'age': [10, 20, 30],
+    }, schema=schema))
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+
+    pinned = table.copy({
+        'scan.snapshot-id': 
str(table.snapshot_manager().get_latest_snapshot().id),
+    })
+    assert create_native_update(pinned, builder.commit_user, ['name']) is None
+
+    update_builder = table.new_batch_write_builder()
+    update = update_builder.new_update().with_update_type(['name', 'age'])
+    changed = pa.Table.from_batches([
+        pa.record_batch([
+            pa.array([2], type=pa.int64()), pa.array(['C']),
+            pa.array([31], type=pa.int32()),
+        ], names=['_ROW_ID', 'name', 'age']),
+        pa.record_batch([
+            pa.array([0], type=pa.int64()), pa.array(['A']),
+            pa.array([11], type=pa.int32()),
+        ], names=['_ROW_ID', 'name', 'age']),
+    ])
+    with patch.object(TableUpdateByRowId, 'update_columns',
+                      side_effect=AssertionError('Python update was 
selected')):
+        messages = update.update_by_arrow_with_row_id(changed)
+    assert messages
+    assert all(file.file_path and table.file_io.exists(file.file_path)
+               for message in messages for file in message.new_files)
+    update_builder.new_commit().commit(messages)
+
+    read_builder = table.new_read_builder()
+    actual = read_builder.new_read().to_arrow(
+        read_builder.new_scan().plan().splits()).sort_by('id')
+    assert actual.select(['id', 'name', 'age']).to_pydict() == {
+        'id': [1, 2, 3], 'name': ['A', 'b', 'C'], 'age': [11, 20, 31],
+    }
+
+
+def test_batch_row_id_delete_uses_rust_deletion_vectors(tmp_path):
+
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('name', pa.string())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true',
+        'data-evolution.enabled': 'true',
+        'deletion-vectors.enabled': 'true',
+        'write.native.enabled': 'true',
+    }), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.Table.from_pydict({
+        'id': [1, 2, 3], 'name': ['a', 'b', 'c'],
+    }, schema=schema))
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+
+    pinned = table.copy({
+        'scan.snapshot-id': 
str(table.snapshot_manager().get_latest_snapshot().id),
+    })
+    assert create_native_delete(pinned, builder.commit_user) is None
+
+    with patch.object(TableDeleteByRowId, 'delete',
+                      side_effect=AssertionError('Python delete was 
selected')):
+        delete_builder = table.new_batch_write_builder()
+        messages = delete_builder.new_update().delete_by_row_id([0, 2, 2])
+        assert messages and sum(len(message.index_adds) for message in 
messages) == 1
+        delete_builder.new_commit().commit(messages)
+
+        predicate_builder = table.new_read_builder().new_predicate_builder()
+        delete_builder = table.new_batch_write_builder()
+        with patch.object(TableRead, 'to_arrow',
+                          side_effect=AssertionError('Python predicate match 
selected')):
+            messages = delete_builder.new_update().delete_by_predicate(
+                predicate_builder.equal('id', 2))
+        assert messages
+        delete_builder.new_commit().commit(messages)
+
+    read_builder = table.new_read_builder()
+    actual = 
read_builder.new_read().to_arrow(read_builder.new_scan().plan().splits())
+    assert actual.num_rows == 0
+
+
+def test_stream_update_and_delete_use_native_writers(tmp_path):
+
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('age', pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true',
+        'data-evolution.enabled': 'true',
+        'deletion-vectors.enabled': 'true',
+        'write.native.enabled': 'true',
+    }), False)
+    table = catalog.get_table('default.t')
+    initial = table.new_batch_write_builder()
+    writer = initial.new_write()
+    writer.write_arrow(pa.Table.from_pydict({
+        'id': [1, 2, 3, 4], 'age': [10, 20, 30, 40],
+    }, schema=schema))
+    initial.new_commit().commit(writer.prepare_commit())
+    writer.close()
+
+    stream = table.new_stream_write_builder()
+    committer = stream.new_commit()
+    update = stream.new_update()
+    with patch.object(TableUpdateByRowId, 'update_columns',
+                      side_effect=AssertionError('Python row-ID update 
selected')):
+        messages = update.update_by_arrow_with_row_id(
+            pa.Table.from_pydict({'_ROW_ID': [0], 'age': [11]}), 10)
+    committer.commit(messages, 10)
+
+    predicate = table.new_read_builder().new_predicate_builder()
+    with patch.object(StreamTableUpdate, '_build_predicate_update_table',
+                      side_effect=AssertionError('Python assignments 
selected')), \
+            patch.object(TableRead, 'to_arrow',
+                         side_effect=AssertionError('Python predicate read 
selected')):
+        messages = update.update_by_predicate(
+            predicate.equal('id', 2), {'age': 22}, 20)
+    committer.commit(messages, 20)
+
+    with patch.object(TableDeleteByRowId, 'delete',
+                      side_effect=AssertionError('Python row-ID delete 
selected')):
+        messages = update.delete_by_row_id([2], 30)
+        committer.commit(messages, 30)
+        with patch.object(TableRead, 'to_arrow',
+                          side_effect=AssertionError('Python predicate read 
selected')):
+            messages = update.delete_by_predicate(predicate.equal('id', 4), 40)
+        committer.commit(messages, 40)
+    committer.close()
+
+    read = table.new_read_builder()
+    actual = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
+    assert actual.select(['id', 'age']).to_pydict() == {
+        'id': [1, 2], 'age': [11, 22],
+    }
+    assert table.snapshot_manager().get_latest_snapshot().commit_identifier == 
40
+
+
+def test_native_batch_update_preserves_input_table_boundaries(tmp_path):
+
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('age', pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true',
+        'data-evolution.enabled': 'true',
+        'write.native.enabled': 'true',
+    }), False)
+    table = catalog.get_table('default.t')
+    for ids in ([1, 2], [3, 4]):
+        builder = table.new_batch_write_builder()
+        writer = builder.new_write()
+        writer.write_arrow(pa.Table.from_pydict({
+            'id': ids, 'age': [value * 10 for value in ids],
+        }, schema=schema))
+        builder.new_commit().commit(writer.prepare_commit())
+        writer.close()
+
+    first = pa.Table.from_batches([
+        pa.record_batch([pa.array([0], type=pa.int64()), pa.array([11])],
+                        names=['_ROW_ID', 'age']),
+        pa.record_batch([pa.array([1], type=pa.int64()), pa.array([22])],
+                        names=['_ROW_ID', 'age']),
+    ])
+    with patch.object(BatchTableUpdate, '_update_by_arrow_batches_with_row_id',
+                      side_effect=AssertionError('Python batch update 
selected')):
+        with pytest.raises(ValueError, match='overlapping first_row_ids.*0'):
+            table.new_batch_write_builder().new_update().with_update_type(
+                ['age']).update_by_arrow_batches_with_row_id(iter([
+                    first.slice(0, 1),
+                    pa.Table.from_pydict({'_ROW_ID': [1], 'age': [23]}),
+                ]))
+
+        builder = table.new_batch_write_builder()
+        messages = 
builder.new_update().update_by_arrow_batches_with_row_id(iter([
+            first,
+            pa.Table.from_pydict({'_ROW_ID': [2], 'age': [33]}),
+            pa.Table.from_pydict({'_ROW_ID': [0], 'id': [10]}),
+        ]))
+    builder.new_commit().commit(messages)
+    read = table.new_read_builder()
+    actual = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
+    assert actual.select(['id', 'age']).to_pydict() == {
+        'id': [2, 3, 4, 10], 'age': [22, 33, 40, 11],
+    }
+
+    before = set(tmp_path.rglob('*.parquet'))
+
+    def failed_input():
+        yield first
+        raise RuntimeError('input generator failed')
+
+    with pytest.raises(RuntimeError, match='input generator failed'):
+        
table.new_batch_write_builder().new_update().update_by_arrow_batches_with_row_id(
+            failed_input())
+    assert set(tmp_path.rglob('*.parquet')) == before
+
+    read_snapshot_id = table.snapshot_manager().get_latest_snapshot().id
+
+    def interleaved_tables():
+        yield pa.Table.from_pydict({'_ROW_ID': [0], 'age': [12]})
+        concurrent = table.new_batch_write_builder()
+        changed = concurrent.new_update().with_update_type(['age'])
+        concurrent.new_commit().commit(changed.update_by_arrow_with_row_id(
+            pa.Table.from_pydict({'_ROW_ID': [1], 'age': [23]})))
+
+    with patch.object(BatchTableUpdate, '_update_by_arrow_batches_with_row_id',
+                      side_effect=AssertionError('Python batch update 
selected')):
+        staged = (table.new_batch_write_builder().new_update()
+                  .with_update_type(['age'])
+                  .update_by_arrow_batches_with_row_id(interleaved_tables()))
+    assert staged and all(message.check_from_snapshot == read_snapshot_id
+                          for message in staged)
+    from pypaimon.write.file_store_commit import _abort_commit_messages
+    _abort_commit_messages(table, staged)
+
+
+def test_native_predicate_update_invokes_callable_by_file_group(tmp_path):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('age', pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true',
+        'data-evolution.enabled': 'true',
+        'write.native.enabled': 'true',
+    }), False)
+    table = catalog.get_table('default.t')
+    for ids in ([1, 2], [3, 4]):
+        builder = table.new_batch_write_builder()
+        writer = builder.new_write()
+        writer.write_arrow(pa.Table.from_pydict({
+            'id': ids, 'age': [value * 10 for value in ids],
+        }, schema=schema))
+        builder.new_commit().commit(writer.prepare_commit())
+        writer.close()
+
+    seen = []
+    predicate = 
table.new_read_builder().new_predicate_builder().greater_or_equal('id', 2)
+    builder = table.new_batch_write_builder()
+    with patch.object(BatchTableUpdate, '_build_predicate_update_table',
+                      side_effect=AssertionError('Python assignments 
selected')), \
+            patch.object(BatchTableUpdate, '_matched_update_scan_table',
+                         side_effect=AssertionError('Python scan planning 
selected')):
+        messages = builder.new_update().update_by_predicate(
+            predicate,
+            {'age': lambda matched: (
+                seen.append(matched.num_rows) or
+                pa.compute.add(matched['age'], 1)
+            )},
+            read_columns=['age'],
+        )
+    assert sorted(seen) == [1, 2]
+    builder.new_commit().commit(messages)
+    read_builder = table.new_read_builder()
+    actual = read_builder.new_read().to_arrow(
+        read_builder.new_scan().plan().splits()).sort_by('id')
+    assert actual.select(['id', 'age']).to_pydict() == {
+        'id': [1, 2, 3, 4], 'age': [10, 21, 31, 41],
+    }
+
+
+def test_native_upsert_matches_duplicate_source_and_target_keys(tmp_path):
+    from pypaimon.table.row.generic_row import GenericRow
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('age', pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true',
+        'data-evolution.enabled': 'true',
+        'write.native.enabled': 'true',
+    }), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.Table.from_pydict({
+        'id': [1, 1, 2], 'age': [10, 11, 20],
+    }, schema=schema))
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+
+    updates = pa.Table.from_pydict({
+        'id': [1, 1, 3], 'age': [100, 101, 30],
+    }, schema=schema)
+    builder = table.new_batch_write_builder()
+    with patch.object(TableUpsertByKey, '_upsert_partition',
+                      side_effect=AssertionError('Python upsert selected')):
+        messages = builder.new_update().upsert_by_arrow_with_key(updates, 
['id'])
+    builder.new_commit().commit(messages)
+    builder = table.new_batch_write_builder()
+    with patch.object(TableUpsertByKey, '_upsert_row_partition',
+                      side_effect=AssertionError('Python row upsert 
selected')):
+        messages = builder.new_update().upsert_by_key([
+            GenericRow([1, 201], table.fields),
+            GenericRow([4, 40], table.fields),
+        ], ['id'])
+    builder.new_commit().commit(messages)
+    read_builder = table.new_read_builder()
+    actual = read_builder.new_read().to_arrow(
+        read_builder.new_scan().plan().splits()).sort_by([('id', 'ascending'),
+                                                          ('age', 
'ascending')])
+    assert actual.select(['id', 'age']).to_pydict() == {
+        'id': [1, 1, 2, 3, 4], 'age': [201, 201, 20, 30, 40],
+    }
+
+    stream = table.new_stream_write_builder()
+    stream_updates = pa.Table.from_pydict({
+        'id': [2, 5], 'age': [25, 50],
+    }, schema=schema)
+    with patch.object(TableUpsertByKey, '_upsert_partition',
+                      side_effect=AssertionError('Python stream selected')):
+        messages = stream.new_update().upsert_by_arrow_with_key(
+            stream_updates, ['id'], 77)
+    stream.new_commit().commit(messages, 77)
+    read_builder = table.new_read_builder()
+    actual = read_builder.new_read().to_arrow(
+        read_builder.new_scan().plan().splits())
+    actual = actual.sort_by([('id', 'ascending'), ('age', 'ascending')])
+    assert actual.select(['id', 'age']).to_pydict() == {
+        'id': [1, 1, 2, 3, 4, 5],
+        'age': [201, 201, 25, 30, 40, 50],
+    }
+    snapshot = table.snapshot_manager().get_latest_snapshot()
+    assert snapshot.commit_identifier == 77
+
+    # If the core operation is unavailable, fall back for the whole upsert.
+    # No private matcher can leave Python coordinating native result indices.
+    builder = table.new_batch_write_builder()
+    fallback_updates = pa.Table.from_pydict({
+        'id': [2, 6], 'age': [26, 60],
+    }, schema=schema)
+    with patch('pypaimon.write.native_update.create_native_upsert', 
return_value=None), \
+            patch('pypaimon.write.native_update.create_native_update', 
return_value=None):
+        messages = 
builder.new_update().upsert_by_arrow_with_key(fallback_updates, ['id'])
+    builder.new_commit().commit(messages)
+    read_builder = table.new_read_builder()
+    actual = read_builder.new_read().to_arrow(
+        read_builder.new_scan().plan().splits())
+    actual = actual.sort_by([('id', 'ascending'), ('age', 'ascending')])
+    assert actual.select(['id', 'age']).to_pydict() == {
+        'id': [1, 1, 2, 3, 4, 5, 6],
+        'age': [201, 201, 26, 30, 40, 50, 60],
+    }
+
+    from pypaimon.write.native_update import NativeTableUpsert
+    before = set(tmp_path.rglob('*.parquet'))
+    snapshot_id = table.snapshot_manager().get_latest_snapshot().id
+    with patch.object(NativeTableUpsert, 'upsert', 
side_effect=RuntimeError('core upsert failed')), \
+            patch.object(TableUpsertByKey, '_upsert_partition',
+                         side_effect=AssertionError('retried as Python Arrow 
upsert')), \
+            patch.object(TableUpsertByKey, '_upsert_row_partition',
+                         side_effect=AssertionError('retried as Python row 
upsert')):
+        with pytest.raises(RuntimeError, match='core upsert failed'):
+            builder.new_update().upsert_by_arrow_with_key(fallback_updates, 
['id'])
+        with pytest.raises(RuntimeError, match='core upsert failed'):
+            builder.new_update().upsert_by_key([GenericRow([2, 27], 
table.fields)], ['id'])
+    assert table.snapshot_manager().get_latest_snapshot().id == snapshot_id
+    assert set(tmp_path.rglob('*.parquet')) == before
+
+
[email protected]('native', [False, True])
[email protected]('values', [
+    pa.array(['bad']), pa.array([2147483648], type=pa.int64()),
+])
+def test_row_id_cast_failure_does_not_write_nulls(tmp_path, native, values):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('value', pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'write.native.enabled': str(native).lower(),
+    }), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.Table.from_pydict({'id': [1], 'value': [10]}, 
schema=schema))
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+    before = set(tmp_path.rglob('*.parquet'))
+    update = pa.Table.from_arrays([pa.array([0], type=pa.int64()), values],
+                                  names=['_ROW_ID', 'value'])
+    with pytest.raises((ValueError, pa.ArrowInvalid)):
+        
table.new_batch_write_builder().new_update().update_by_arrow_with_row_id(update)
+    assert set(tmp_path.rglob('*.parquet')) == before
+    read = table.new_read_builder()
+    actual = read.new_read().to_arrow(read.new_scan().plan().splits())
+    assert actual.select(['id', 'value']).to_pydict() == {'id': [1], 'value': 
[10]}
+
+
[email protected]('native', [False, True])
[email protected]('values,target', [
+    (pa.array([1.0]), pa.string()),
+    (pa.array([1e-6], type=pa.float32()), pa.string()),
+    (pa.array([-0.0]), pa.int32()),
+    (pa.array([1.234]), pa.decimal128(10, 2)),
+    (pa.array(['-1000e-5']), pa.decimal128(10, 2)),
+    (pa.array(['1.234']), pa.decimal128(10, 2)),
+    (pa.array([16777218], type=pa.int64()), pa.float32()),
+    (pa.array([1], type=pa.timestamp('us')), pa.string()),
+    (pa.array([1234], type=pa.timestamp('us')), pa.timestamp('ms')),
+    (pa.array([1234], type=pa.timestamp('us')), pa.date32()),
+    (pa.array([1.5]).dictionary_encode(), pa.int32()),
+    (pa.array(['1.234']).dictionary_encode(), pa.decimal128(10, 2)),
+    (pa.array([1.0]).dictionary_encode(), pa.string()),
+    (pa.array([[1.5]]), pa.list_(pa.int32())),
+    (pa.array([[1.0, None]]), pa.list_(pa.int32())),
+    (pa.array([1234567], type=pa.timestamp('ns')), pa.time32('ms')),
+    (pa.array([86400001000000], type=pa.timestamp('ns')), pa.time32('ms')),
+    (pa.array([-1000000], type=pa.timestamp('ns')), pa.time32('ms')),
+    (pa.array(['yes']), pa.bool_()),
+    (pa.array(['f']), pa.bool_()),
+    (pa.array(['TRUE']), pa.bool_()),
+    (pa.array(['1970-01-01 00:00:00.123456']), pa.timestamp('ms')),
+    (pa.array(['1970-01-01 00:00:00.0000']), pa.timestamp('ms')),
+    (pa.array(['1970-01-01 00:00:00.123']), pa.timestamp('ms')),
+    (pa.array(['1970-01-01T01']), pa.timestamp('ms')),
+    (pa.array(['1970-01-01 00:00:00Z']), pa.timestamp('ms')),
+    (pa.array([0], type=pa.date64()), pa.string()),
+    (pa.array([0], type=pa.timestamp('us', 'Asia/Shanghai')), 
pa.timestamp('us')),
+    (pa.array([-1000], type=pa.timestamp('us', 'Asia/Shanghai')), 
pa.timestamp('ms')),
+    (pa.array([-1001], type=pa.timestamp('us', 'Asia/Shanghai')), 
pa.timestamp('ms')),
+    (pa.array([42], type=pa.int64()), pa.binary()),
+    (pa.array([None], type=pa.int64()), pa.binary()),
+    (pa.array([b'1.25']), pa.float64()),
+    (pa.array([b'1.25'], type=pa.large_binary()), pa.float64()),
+    (pa.array([b'1.25']), pa.decimal128(6, 2)),
+    (pa.array([b'1.234']), pa.decimal128(6, 2)),
+    (pa.array([b'0042']), pa.int32()),
+    (pa.array([b'0x2a']), pa.int32()),
+    (pa.array([b'0Xffffffff'], type=pa.large_binary()), pa.int32()),
+    (pa.array([b'+42']), pa.int32()),
+    (pa.array(['0x2a']), pa.int32()),
+    (pa.array(['+42']), pa.int32()),
+    (pa.array([b'true']), pa.bool_()),
+    (pa.array([b'yes']), pa.bool_()),
+    (pa.array(['00:00:01.234567']), pa.time32('ms')),
+    (pa.array(['2024-01-02 12:34:56']), pa.date32()),
+    (pa.array(['2024-01-02']), pa.date32()),
+    (pa.array([1.5]), pa.timestamp('ms')),
+    (pa.array([1000000], type=pa.timestamp('us')), pa.float64()),
+    (pa.array([1.5]), pa.date32()),
+    (pa.array([1.5]), pa.time32('ms')),
+    (pa.array([1], type=pa.int32()), pa.timestamp('ms')),
+    (pa.array([2.675]), pa.decimal128(10, 2)),
+    (pa.array([2.25], type=pa.float32()), pa.decimal128(10, 1)),
+    (pa.array([0.1]), pa.decimal128(38, 20)),
+])
+def test_native_assignment_cast_matches_pyarrow(tmp_path, native, values, 
target):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('value', target)])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'write.native.enabled': str(native).lower(),
+    }), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.Table.from_pydict({'id': [1], 'value': [None]}, 
schema=schema))
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+    before = set(tmp_path.rglob('*.parquet'))
+    update = table.new_batch_write_builder()
+    try:
+        expected = values.cast(target).to_pylist()
+    except pa.ArrowException:
+        with pytest.raises((ValueError, pa.ArrowException)):
+            update.new_update().update_by_predicate(None, {'value': values})
+        assert set(tmp_path.rglob('*.parquet')) == before
+        expected = [None]
+    else:
+        messages = update.new_update().update_by_predicate(None, {'value': 
values})
+        update.new_commit().commit(messages)
+    read = table.new_read_builder()
+    actual = read.new_read().to_arrow(read.new_scan().plan().splits())
+    assert actual['value'].to_pylist() == expected
+
+
[email protected]('native', [False, True])
[email protected]('callable_assignment', [False, True])
+def test_predicate_assignment_order_with_later_partial_file(
+        tmp_path, native, callable_assignment):
+    from contextlib import nullcontext
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('age', pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true',
+        'data-evolution.enabled': 'true',
+        'write.native.enabled': str(native).lower(),
+    }), False)
+    table = catalog.get_table('default.t')
+    for ids in ([1, 2], [3, 4]):
+        builder = table.new_batch_write_builder()
+        writer = builder.new_write()
+        writer.write_arrow(pa.Table.from_pydict({
+            'id': ids, 'age': [value * 10 for value in ids],
+        }, schema=schema))
+        builder.new_commit().commit(writer.prepare_commit())
+        writer.close()
+    builder = table.new_batch_write_builder()
+    messages = builder.new_update().update_by_arrow_with_row_id(
+        pa.table({'_ROW_ID': [2], 'age': [33]}))
+    builder.new_commit().commit(messages)
+
+    seen = []
+
+    def assign(matched):
+        seen.append(matched['id'].to_pylist())
+        return pa.compute.add(matched['age'], 1)
+
+    assignment = assign if callable_assignment else pa.chunked_array(
+        [[101], [102, 103, 104]], type=pa.int32())
+    guard = (patch.object(BatchTableUpdate, '_matched_update_scan_table',
+                          side_effect=AssertionError('Python planning 
selected'))
+             if native else nullcontext())
+    with guard:
+        messages = builder.new_update().update_by_predicate(
+            None, {'age': assignment},
+            read_columns=['id', 'age'] if callable_assignment else None)
+    builder.new_commit().commit(messages)
+    if callable_assignment:
+        assert seen == [[1, 2], [3, 4]]
+    read = table.new_read_builder()
+    actual = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
+    assert actual.select(['id', 'age']).to_pydict() == {
+        'id': [1, 2, 3, 4],
+        'age': [11, 21, 34, 41] if callable_assignment else [101, 102, 103, 
104],
+    }
+
+
[email protected]('stream', [False, True])
+def 
test_native_row_upsert_uses_public_operation_with_composite_null_keys(tmp_path, 
stream):
+    from pypaimon.table.row.generic_row import GenericRow
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([
+        ('id', pa.int32()), ('part', pa.string()), ('age', pa.int32()), 
('keep', pa.string()),
+    ])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'write.native.enabled': 'true',
+    }), False)
+    table = catalog.get_table('default.t')
+    seed = table.new_batch_write_builder()
+    writer = seed.new_write()
+    writer.write_arrow(pa.Table.from_pydict({
+        'id': [1, 1, None, 2], 'part': ['a', 'a', 'n', 'b'],
+        'age': [10, 11, 20, 30], 'keep': ['old'] * 4,
+    }, schema=schema))
+    seed.new_commit().commit(writer.prepare_commit())
+    writer.close()
+    fields = list(reversed(table.fields))
+    rows = (GenericRow(values, fields) for values in [
+        ['new', 101, 'a', 1], ['new', 102, 'a', 1],
+        ['new', None, 'n', None], ['new', 40, 'c', 3],
+    ])
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['age'])
+    with patch.object(TableUpsertByKey, '_upsert_row_partition',
+                      side_effect=AssertionError('Python upsert selected')):
+        if stream:
+            messages = update.upsert_by_key(rows, ['id', 'part'], 88)
+            builder.new_commit().commit(messages, 88)
+        else:
+            messages = update.upsert_by_key(rows, ['id', 'part'])
+            builder.new_commit().commit(messages)
+    read = table.new_read_builder()
+    actual = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
+    assert actual.to_pydict() == {
+        'id': [1, 1, 2, 3, None], 'part': ['a', 'a', 'b', 'c', 'n'],
+        'age': [102, 102, 30, 40, None], 'keep': ['old', 'old', 'old', 'new', 
'old'],
+    }
+
+
[email protected]('case', ['partial', 'float-key', 'empty-columns'])
+def test_row_upsert_unsupported_inputs_keep_python_semantics(tmp_path, case):
+    from pypaimon.table.row.generic_row import GenericRow
+    from pypaimon.write.native_update import NativeTableUpsert
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([
+        ('id', pa.float64() if case == 'float-key' else pa.int32()),
+        ('age', pa.int32()), ('region', pa.string()),
+    ])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        schema, options={
+            'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+            'write.native.enabled': 'true',
+        }), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.Table.from_pydict({
+        'id': [1, 2], 'age': [10, 20], 'region': ['east', 'west'],
+    }, schema=schema))
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+    if case == 'partial':
+        rows = [GenericRow([1, 11], table.fields[:2])]
+    else:
+        rows = [GenericRow(values, table.fields) for values in [
+            [1, 11, 'east'], [3, 30, 'east'],
+        ]]
+    if case == 'empty-columns':
+        before = set(tmp_path.rglob('*.parquet'))
+        with patch.object(NativeTableUpsert, 'upsert',
+                          side_effect=AssertionError('Empty row update became 
all columns')), \
+                pytest.raises(ValueError, match='column_names cannot be 
empty'):
+            builder.new_update().with_update_type([]).upsert_by_key(rows, 
['id'])
+        assert set(tmp_path.rglob('*.parquet')) == before
+        read = table.new_read_builder()
+        actual = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
+        assert actual.to_pydict() == {
+            'id': [1, 2], 'age': [10, 20], 'region': ['east', 'west'],
+        }
+        return
+    with patch.object(NativeTableUpsert, 'upsert',
+                      side_effect=AssertionError('Unsupported native upsert 
selected')):
+        messages = 
builder.new_update().with_update_type(['age']).upsert_by_key(rows, ['id'])
+    builder.new_commit().commit(messages)
+    read = table.new_read_builder()
+    actual = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
+    assert actual.to_pydict() == {
+        'id': [1, 2] if case == 'partial' else [1, 2, 3],
+        'age': [11, 20] if case == 'partial' else [11, 20, 30],
+        'region': ['east', 'west'] if case == 'partial' else ['east', 'west', 
'east'],
+    }
+
+
[email protected]('native', [False, True])
[email protected]('grouped', [False, True])
[email protected]('empty_chunks', [False, True])
[email protected]('values,target,row_id_type', [
+    (pa.array([99], type=pa.int32()), pa.int32(), pa.int32()),
+    (pa.array([99], type=pa.int32()), pa.int32(), pa.uint64()),
+    (pa.array([99], type=pa.int32()), pa.int32(),
+     pa.dictionary(pa.int8(), pa.int32())),
+    (pa.array([1.0]), pa.string(), pa.int64()),
+    (pa.array([0], type=pa.timestamp('us')), pa.string(), pa.int64()),
+    (pa.array(['yes']), pa.bool_(), pa.int64()),
+    (pa.array([1.5]), pa.int32(), pa.int64()),
+    (pa.array([2 ** 63 - 1], type=pa.int64()), pa.float64(), pa.int64()),
+    (pa.array([-1234567], type=pa.timestamp('ns')), pa.timestamp('ms'), 
pa.int64()),
+    (pa.array([1234567], type=pa.timestamp('ns')), pa.time32('ms'), 
pa.int64()),
+    (pa.array([99], type=pa.int32()), pa.int32(), pa.uint32()),
+    (pa.array(['1970-01-01 00:00:00.123456']), pa.timestamp('ms'), pa.int32()),
+    (pa.array(['1970-01-01 00:00:00.123']), pa.timestamp('ms'), pa.int32()),
+    (pa.array([0], type=pa.date64()), pa.string(), pa.int32()),
+    (pa.array([0], type=pa.timestamp('us', 'Asia/Shanghai')), 
pa.timestamp('us'), pa.int32()),
+    (pa.array([-1001], type=pa.timestamp('us', 'Asia/Shanghai')), 
pa.timestamp('ms'), pa.int32()),
+    (pa.array([42], type=pa.int64()), pa.binary(), pa.int32()),
+    (pa.array([None], type=pa.int64()), pa.binary(), pa.int32()),
+    (pa.array([b'1.25']), pa.float64(), pa.int32()),
+    (pa.array([b'1.25'], type=pa.large_binary()), pa.float64(), pa.int32()),
+    (pa.array([b'1.234']), pa.decimal128(6, 2), pa.int32()),
+    (pa.array([b'0x2a']), pa.int32(), pa.int32()),
+    (pa.array([b'+42']), pa.int32(), pa.int32()),
+    (pa.array(['00:00:01.234567']), pa.time32('ms'), pa.int64()),
+    (pa.array(['2024-01-02 12:34:56']), pa.date32(), pa.int64()),
+    (pa.array(['2024-01-02']), pa.date32(), pa.int64()),
+    (pa.array([1.5]), pa.timestamp('ms'), pa.int64()),
+    (pa.array([1000000], type=pa.timestamp('us')), pa.float64(), pa.int64()),
+    (pa.array([1.5]), pa.date32(), pa.int64()),
+    (pa.array([1.5]), pa.time32('ms'), pa.int64()),
+    (pa.array([1], type=pa.int32()), pa.timestamp('ms'), pa.int64()),
+    (pa.array([2.675]), pa.decimal128(10, 2), pa.int64()),
+    (pa.array([2.25], type=pa.float32()), pa.decimal128(10, 1), pa.int64()),
+    (pa.array([0.1]), pa.decimal128(38, 20), pa.int64()),
+])
+def test_native_row_id_input_conversion_matches_python(
+        tmp_path, native, grouped, empty_chunks, values, target, row_id_type):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('value', target)])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'write.native.enabled': str(native).lower(),
+    }), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.table({'id': [1], 'value': [None]}, schema=schema))
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+    data = pa.table({'_ROW_ID': pa.array([0], type=row_id_type), 'value': 
values})
+    if empty_chunks:
+        batch = data.to_batches()[0]
+        data = pa.Table.from_batches([batch.slice(0, 0), batch, batch.slice(1, 
0)])
+    before = set(tmp_path.rglob('*.parquet'))
+    updater = builder.new_update()
+
+    def prepare():
+        if grouped:
+            return updater.update_by_arrow_batches_with_row_id(iter([data]))
+        return updater.update_by_arrow_with_row_id(data)
+
+    try:
+        expected = TableUpdateByRowId._coerce_column(values, 
target).to_pylist()
+    except (ValueError, pa.ArrowException):
+        with pytest.raises((ValueError, pa.ArrowException)):
+            prepare()
+        expected = [None]
+        assert set(tmp_path.rglob('*.parquet')) == before
+    else:
+        # Ensure a native case does not silently exercise the Python fallback.
+        if native:
+            with patch.object(TableUpdateByRowId, 'update_columns',
+                              side_effect=AssertionError('Python update 
selected')):
+                messages = prepare()
+        else:
+            messages = prepare()
+        builder.new_commit().commit(messages)
+    read = table.new_read_builder()
+    result = read.new_read().to_arrow(read.new_scan().plan().splits())
+    assert result['value'].to_pylist() == expected
+
+
[email protected]('native', [False, True])
[email protected]('duplicate', ['_ROW_ID', 'value', 'unused'])
+def test_row_id_update_rejects_only_referenced_duplicate_columns(tmp_path, 
native, duplicate):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('value', pa.int32())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'write.native.enabled': str(native).lower(),
+    }), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.table({'id': [1, 2], 'value': [10, 20]}, 
schema=schema))
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+    data = pa.Table.from_arrays([
+        pa.array([0]), pa.array([100]), pa.array([1]), pa.array([2]),
+    ], names=['_ROW_ID', 'value', duplicate, duplicate])
+    before = set(tmp_path.rglob('*.parquet'))
+    # Repeating configuration names remains valid; ambiguity is in input 
fields.
+    updater = builder.new_update().with_update_type(['value', 'value'])
+    if duplicate == 'unused':
+        messages = updater.update_by_arrow_with_row_id(data)
+        builder.new_commit().commit(messages)
+    else:
+        with pytest.raises((KeyError, ValueError, pa.ArrowException)):
+            updater.update_by_arrow_with_row_id(data)
+        assert set(tmp_path.rglob('*.parquet')) == before
+    read = table.new_read_builder()
+    actual = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
+    assert actual['value'].to_pylist() == ([100, 20] if duplicate == 'unused' 
else [10, 20])
+
+
[email protected]('native', [False, True])
[email protected]('values,target', [
+    (pa.array([{'a': 1, 'b': 1.5}], type=pa.struct([('a', pa.int32()), ('b', 
pa.float64())])),
+     pa.struct([('a', pa.string()), ('b', pa.int32())])),
+    (pa.array([[(1, 1.5)]], type=pa.map_(pa.int32(), pa.float64())), 
pa.map_(pa.string(), pa.int32())),
+    (pa.array([{'a': 1, 'b': 2.0}], type=pa.struct([('a', pa.int32()), ('b', 
pa.float64())])),
+     pa.struct([('a', pa.string()), ('b', pa.int32())])),
+    (pa.StructArray.from_arrays([pa.array([2 ** 31, 7])], names=['a'], 
mask=pa.array([True, False])),
+     pa.struct([('a', pa.int32())])),
+    (pa.ListArray.from_arrays([0, 1, 2], pa.array([2 ** 31, 7]), 
mask=pa.array([True, False])),
+     pa.list_(pa.int32())),
+    (pa.MapArray.from_arrays([0, 1, 2], pa.array(['a', 'b']), pa.array([1.5, 
7.5]),
+                             mask=pa.array([True, False])), 
pa.map_(pa.string(), pa.int32())),
+])
+def 
test_core_nested_row_update_uses_whole_column_constructor_fallback(tmp_path, 
native, values, target):
+    from pypaimon.write.native_commit import create_native_write_table
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('value', target)])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'write.native.enabled': 'false',
+    }), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow(pa.table({'id': list(range(len(values))), 'value': 
[None] * len(values)}, schema=schema))
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+    # Exercise core nested conversion directly even when dispatch would fall 
back.
+    builder = (create_native_write_table(table) if native else 
table).new_batch_write_builder()
+    data = pa.table({'_ROW_ID': list(range(len(values))), 'value': values})
+    before = set(tmp_path.rglob('*.parquet'))
+    try:
+        expected = TableUpdateByRowId._coerce_column(values, 
target).to_pylist()
+    except (ValueError, pa.ArrowException):
+        with pytest.raises((ValueError, pa.ArrowException)):
+            builder.new_update().update_by_arrow_with_row_id(data)
+        assert set(tmp_path.rglob('*.parquet')) == before
+        expected = [None] * len(values)
+    else:
+        messages = builder.new_update().update_by_arrow_with_row_id(data)
+        builder.new_commit().commit(messages)
+    read = table.new_read_builder()
+    actual = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
+    assert actual['value'].to_pylist() == expected
+
+
[email protected]('layout', [
+    'legacy', 'canonical', 'ordinary', 'directory', 'external', 'float', 
'double', 'external-binary',
+])
[email protected]('operation', ['row', 'predicate'])
+def test_update_dispatch_preserves_existing_partition_paths(tmp_path, layout, 
operation):
+    from pypaimon.write.native_commit import create_native_write_table
+    from pypaimon.write.native_update import NativeBatchTableUpdate, 
NativePredicateTableUpdate
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
+    catalog.create_database('default', True)
+    part_type = {'float': pa.float32(), 'double': pa.float64(), 
'external-binary': pa.binary()}.get(
+        layout, pa.string())
+    schema = pa.schema([('id', pa.int32()), ('part', part_type), ('value', 
pa.int32())])
+    options = {
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'write.native.enabled': str(layout == 'canonical').lower(),
+    }
+    if layout == 'directory':
+        options['data-file.path-directory'] = 'data'
+    if layout.startswith('external'):
+        options['data-file.external-paths'] = (tmp_path / 'external').as_uri()
+        options['data-file.external-paths.strategy'] = 'round-robin'
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        schema, partition_keys=['part'], options=options), False)
+    table = catalog.get_table('default.t')
+    seed_table = create_native_write_table(table) if layout == 'canonical' 
else table
+    builder = seed_table.new_batch_write_builder()
+    writer = builder.new_write()
+    parts = ['east', 'west'] if layout == 'ordinary' else ['a/b', 'a%b']
+    if layout in ('float', 'double'):
+        parts = [1.5, 2.25]
+    elif layout == 'external-binary':
+        parts = [b'x', b'a/b']
+    data = pa.table({'id': [1, 2], 'part': parts, 'value': [10, 20]}, 
schema=schema)
+    writer.write_arrow(data.to_batches()[0] if layout == 'canonical' else data)
+    builder.new_commit().commit(writer.prepare_commit())
+    if layout != 'canonical':
+        writer.close()
+    table = table.copy({'write.native.enabled': 'true'})
+    native_calls = []
+    wrapper = NativeBatchTableUpdate if operation == 'row' else 
NativePredicateTableUpdate
+    method = 'update_by_arrow_with_row_id' if operation == 'row' else 'update'
+    original = getattr(wrapper, method)
+
+    def track(*args, **kwargs):
+        native_calls.append(operation)
+        return original(*args, **kwargs)
+
+    # Check a second update too: fallback outputs must remain readable on the
+    # next operation, and canonical files must continue using native updates.
+    with patch.object(wrapper, method, track):
+        for value in [11, 12]:
+            builder = table.new_batch_write_builder()
+            if operation == 'row':
+                messages = builder.new_update().update_by_arrow_with_row_id(
+                    pa.table({'_ROW_ID': [0, 1], 'value': pa.array([value, 
value], pa.int32())}))
+            else:
+                messages = builder.new_update().update_by_predicate(None, 
{'value': value})
+            builder.new_commit().commit(messages)
+            read = table.new_read_builder()
+            actual = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
+            assert actual.to_pydict() == {'id': [1, 2], 'part': parts, 
'value': [value, value]}
+    expected_calls = 2 if layout in ('canonical', 'ordinary', 'external') else 0
+    assert len(native_calls) == expected_calls
diff --git a/paimon-python/pypaimon/tests/table_update_test.py 
b/paimon-python/pypaimon/tests/table_update_test.py
index 25116b3c3b..262b9726c7 100644
--- a/paimon-python/pypaimon/tests/table_update_test.py
+++ b/paimon-python/pypaimon/tests/table_update_test.py
@@ -336,6 +336,7 @@ class _TableUpdateTestBase(DataEvolutionTestBase):
             result['name'].to_pylist(),
         )
 
+    @pytest.mark.python_write
     def test_update_by_predicate_processes_one_file_group_at_a_time(self):
         from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
 
@@ -368,6 +369,7 @@ class _TableUpdateTestBase(DataEvolutionTestBase):
             self._read_all(table)['city'].to_pylist(),
         )
 
+    @pytest.mark.python_write
     def test_predicate_update_aborts_groups_after_later_failure(self):
         from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
 
@@ -458,6 +460,7 @@ class _TableUpdateTestBase(DataEvolutionTestBase):
             self._read_all(table)['age'].to_pylist(),
         )
 
+    @pytest.mark.python_write
     def test_literal_predicate_update_projects_only_row_id(self):
         table = self._create_seeded_table()
         pb = table.new_read_builder().new_predicate_builder()
diff --git a/paimon-python/pypaimon/write/native_commit.py 
b/paimon-python/pypaimon/write/native_commit.py
index 1eb50e09bb..39b86cabe0 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -24,7 +24,9 @@ from pypaimon.read.native_plan import (
     _catalog_context_options, _catalog_metastore, _option_value_to_string,
     _resolved_schema_file_io_options)
 from pypaimon.utils.file_store_path_factory import canonical_data_file_path
-from pypaimon.write.commit_message_serializer import serialize_commit_message
+from pypaimon.write.commit_message_serializer import (
+    deserialize_commit_message, serialize_commit_message,
+)
 
 
 _DEFAULT_MANIFEST_TARGET_SIZE = 8 * 1024 * 1024
@@ -183,3 +185,15 @@ def to_native_commit_messages(table, messages):
     return [NativeCommitMessage.deserialize(
         serialize_commit_message(message, table.partition_keys_fields), 
version=14)
         for message in messages]
+
+
+def from_native_commit_messages(table, messages):
+    """Decode native v14 messages for PyPaimon's existing commit API."""
+    decoded = [deserialize_commit_message(
+        message.serialize(), table.partition_keys_fields,
+        table.trimmed_primary_keys_fields) for message in messages]
+    for message in decoded:
+        for file in message.new_files + message.changelog_files:
+            file.file_path = file.external_path or canonical_data_file_path(
+                table, message.partition, message.bucket, file.file_name)
+    return decoded
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
new file mode 100644
index 0000000000..d4c47367b0
--- /dev/null
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -0,0 +1,304 @@
+# 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.
+
+"""Optional native row-ID updates for batch data-evolution tables."""
+
+import pyarrow as pa
+
+from pypaimon.schema.data_types import PyarrowFieldParser
+from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
+from pypaimon.snapshot.time_travel_util import SCAN_KEYS
+from pypaimon.table.file_store_table import FileStoreTable
+from pypaimon.write.native_commit import (
+    create_native_write_table, from_native_commit_messages,
+)
+from pypaimon.write.native_write import native_write_available, 
_native_partition_types_supported
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId, 
_RowIdUpdateFileWriter
+from pypaimon.write.row_utils import value_for_arrow
+
+
+def _native_row_id_table(table):
+    """Resolve an eligible table before creating a native row-ID writer."""
+    if (type(table) is not FileStoreTable
+            or not table.options.native_write_enabled()
+            or not table.options.data_evolution_enabled()
+            or not table.options.row_tracking_enabled()
+            or table.options.data_file_path_directory() is not None
+            or not _RowIdUpdateFileWriter.supports_table(table)
+            or any(table.options.options.contains_key(key) for key in 
SCAN_KEYS)
+            or not native_write_available()):
+        return None
+    schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
+    if not _native_partition_types_supported(schema, table.partition_keys):
+        return None
+    if not _native_update_paths_supported(table):
+        return None
+    return create_native_write_table(table)
+
+
+def _native_update_paths_supported(table):
+    """Keep legacy Python partition directories on the path-aware Python 
updater."""
+    if not table.partition_keys:
+        return True
+    factory = table.path_factory()
+    # Core update scans for itself, unlike native reads which receive splits
+    # with repaired file paths. Decide before callbacks or staging can start.
+    splits = table.new_read_builder().new_scan().plan_for_write().splits()
+    bucket_files = {}
+    for split in splits:
+        partition = tuple(split.partition.values)
+        bucket_path = factory.bucket_path(partition, split.bucket)
+        if bucket_path == factory.bucket_path(partition, split.bucket, 
canonical_partition=True):
+            continue
+        candidates = [file for file in split.files if not file.external_path]
+        if not candidates:
+            continue
+        if bucket_path not in bucket_files:
+            bucket_files[bucket_path] = {
+                status.base_name for status in 
table.file_io.list_status(bucket_path)
+            }
+        # Python reads prefer an existing legacy path even if a canonical copy
+        # also exists. Canonical-only files and explicit external paths are 
safe.
+        if any(file.file_name in bucket_files[bucket_path] for file in 
candidates):
+            return False
+    return True
+
+
+def _native_update_table(table, columns=None):
+    schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
+    selected = schema.names if columns is None else columns
+    if any(pa.types.is_nested(schema.field(name).type)
+           for name in selected if name in schema.names):
+        return None
+    return _native_row_id_table(table)
+
+
+def create_native_update(table, commit_user, columns):
+    """Use the public core updater for direct and grouped row-ID updates."""
+    native_table = _native_update_table(table, columns)
+    if native_table is None:
+        return None
+    writer = (native_table.new_batch_write_builder()
+              ._with_commit_user(commit_user)
+              .new_update())
+    if columns is not None:
+        writer.with_update_type(columns)
+    return NativeBatchTableUpdate(table, writer)
+
+
+def create_native_update_by_row_id(table, commit_user, commit_identifier):
+    """Create a core updater sharing one snapshot across incremental calls."""
+    native_table = _native_update_table(table)
+    if native_table is None:
+        return None
+    if commit_identifier == BATCH_COMMIT_IDENTIFIER:
+        writer = 
(native_table.new_batch_write_builder()._with_commit_user(commit_user)
+                  .new_update().new_update_by_row_id())
+    else:
+        writer = 
(native_table.new_stream_write_builder().with_commit_user(commit_user)
+                  .new_update().new_update_by_row_id(commit_identifier))
+    return NativeTableUpdateByRowId(
+        table, commit_user, commit_identifier, writer)
+
+
+def _supported_upsert_key_type(data_type):
+    return any(check(data_type) for check in (
+        pa.types.is_boolean, pa.types.is_integer, pa.types.is_string,
+        pa.types.is_large_string, pa.types.is_binary, pa.types.is_large_binary,
+        pa.types.is_fixed_size_binary, pa.types.is_date, pa.types.is_decimal,
+    ))
+
+
+def create_native_upsert(table, commit_user, data, keys, columns):
+    """Prepare one core upsert from full Arrow rows or named row values."""
+    native_table = _native_row_id_table(table)
+    if native_table is None:
+        return None
+    fields = table.table_schema.fields
+    schema = PyarrowFieldParser.from_paimon_schema(fields)
+    if (any(not _supported_upsert_key_type(schema.field(key).type) for key in 
set(keys + table.partition_keys))
+            or any(pa.types.is_nested(schema.field(name).type) for name in 
columns)):
+        return None
+    if not isinstance(data, pa.Table):
+        # Missing fields retain their row-object semantics on the fallback
+        # path; converting them to Arrow NULLs would change update behavior.
+        if not columns or any(set(values) != set(schema.names) for values in 
data):
+            return None
+        data = pa.Table.from_pydict({
+            field.name: [value_for_arrow(values[field.name], field) for values 
in data]
+            for field in fields
+        }, schema=schema)
+    if (len(data.column_names) != len(schema.names)
+            or set(data.column_names) != set(schema.names)
+            or any(data.schema.field(name).type != schema.field(name).type
+                   for name in schema.names)):
+        return None
+    writer = (native_table.new_batch_write_builder()
+              ._with_commit_user(commit_user)
+              .new_update()
+              .with_update_type(columns))
+    return NativeTableUpsert(table, writer, keys, data)
+
+
+def create_native_predicate_update(table, commit_user, columns, predicate):
+    """Prepare a public core operation before any assignment can run."""
+    schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
+    if any(pa.types.is_nested(schema.field(name).type)
+           for name in columns if name in schema.names):
+        return None
+    native_table = _native_row_id_table(table)
+    if native_table is None:
+        return None
+    from pypaimon.read.native_plan import _predicate_to_native
+    native_predicate = None if predicate is None else 
_predicate_to_native(predicate)
+    if native_predicate is not None:
+        # Check predicate translation while fallback is still safe. Planning,
+        # reading and callback execution belong to the core operation below.
+        native_table.new_read_builder().with_filter(native_predicate)
+    writer = (native_table.new_batch_write_builder()
+              ._with_commit_user(commit_user).new_update())
+    return NativePredicateTableUpdate(table, writer, native_predicate)
+
+
+def native_predicate_row_ids(scan_table, predicate, splits):
+    """Match a batch delete predicate in Rust and return its row IDs."""
+    from pypaimon.read.native_plan import (
+        _prepare_native_read, native_split_bridge_available,
+        native_split_from_python,
+    )
+    if not native_split_bridge_available():
+        return None
+    reader = _prepare_native_read(
+        scan_table, predicate=predicate, projection=['_ROW_ID']
+    )
+    row_ids = []
+    for split in splits:
+        for batch in reader([native_split_from_python(split)]):
+            row_ids.extend(batch.column('_ROW_ID').to_pylist())
+    return row_ids
+
+
+def create_native_delete(table, commit_user):
+    """Select Rust's deletion-vector writer for supported batch deletes."""
+    if not table.options.deletion_vectors_enabled(False):
+        return None
+    native_table = _native_row_id_table(table)
+    if native_table is None:
+        return None
+    writer = (native_table.new_batch_write_builder()
+              ._with_commit_user(commit_user)
+              .new_update())
+    return NativeBatchTableUpdate(table, writer)
+
+
+def _raise_native_row_id_error(error):
+    detail = str(error)
+    if 'duplicate UPDATE operations' in detail:
+        raise ValueError('duplicate _ROW_ID: ' + detail) from error
+    if 'No file found for _ROW_ID' in detail:
+        raise ValueError(
+            detail + ' does not belong to any valid range') from error
+    raise error
+
+
+class NativeBatchTableUpdate:
+    """Wrap public core operations and decode their commit messages."""
+
+    def __init__(self, table, writer):
+        self.table = table
+        self.writer = writer
+
+    def update_by_arrow_with_row_id(self, data: pa.Table):
+        try:
+            messages = self.writer.update_by_arrow_with_row_id(data)
+        except ValueError as error:
+            _raise_native_row_id_error(error)
+        return from_native_commit_messages(self.table, messages)
+
+    def update_by_arrow_batches_with_row_id(self, tables):
+        try:
+            messages = self.writer.update_by_arrow_batches_with_row_id(tables)
+        except ValueError as error:
+            _raise_native_row_id_error(error)
+        return from_native_commit_messages(self.table, messages)
+
+    def delete_by_row_id(self, row_ids):
+        ids = []
+        for row_id in row_ids:
+            if row_id is None:
+                raise ValueError('_ROW_ID value must not be null.')
+            ids.append(int(row_id))
+        try:
+            messages = self.writer.delete_by_row_id(ids)
+        except ValueError as error:
+            _raise_native_row_id_error(error)
+        return from_native_commit_messages(self.table, messages)
+
+
+class NativeTableUpdateByRowId(TableUpdateByRowId):
+    """Adapt Python row inputs around the core incremental Arrow updater."""
+
+    def __init__(self, table, commit_user, commit_identifier, writer):
+        self.table = table
+        self.commit_user = commit_user
+        self.commit_identifier = commit_identifier
+        self.writer = writer
+
+    @property
+    def commit_messages(self):
+        return from_native_commit_messages(self.table, 
self.writer.commit_messages)
+
+    def update_columns(self, data, column_names):
+        try:
+            messages = self.writer.update_columns(data, column_names)
+        except ValueError as error:
+            _raise_native_row_id_error(error)
+        return from_native_commit_messages(self.table, messages)
+
+    def _write_row_columns(self, data, column_names, blob_object_columns):
+        # This adapter is selected only for plain Parquet tables without blobs.
+        return self.update_columns(data, column_names)
+
+
+class NativeTableUpsert:
+    """Submit full Arrow rows to the core Rust upsert writer."""
+
+    def __init__(self, table, writer, keys, data):
+        self.table = table
+        self.writer = writer
+        self.keys = keys
+        self.data = data
+
+    def upsert(self):
+        return from_native_commit_messages(
+            self.table,
+            self.writer.upsert_by_arrow_with_key(self.data, self.keys))
+
+
+class NativePredicateTableUpdate:
+    """Convert operation inputs and commit messages around the core updater."""
+
+    def __init__(self, table, writer, predicate):
+        self.table = table
+        self.writer = writer
+        self.predicate = predicate
+
+    def update(self, assignments, read_columns):
+        return from_native_commit_messages(
+            self.table,
+            self.writer.update_by_predicate(
+                self.predicate, dict(assignments), list(read_columns or ())))
diff --git a/paimon-python/pypaimon/write/native_write.py 
b/paimon-python/pypaimon/write/native_write.py
index 5185731614..2d631019c6 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -24,9 +24,9 @@ from pypaimon.common.options.core_options import CoreOptions, 
MergeEngine
 from pypaimon.schema.arrow_schema import arrow_schemas_compatible, 
normalize_arrow_strings
 from pypaimon.schema.data_types import PyarrowFieldParser, is_blob_file_field
 from pypaimon.table.bucket_mode import BucketMode
-from pypaimon.utils.file_store_path_factory import canonical_data_file_path
-from pypaimon.write.commit_message_serializer import deserialize_commit_message
-from pypaimon.write.native_commit import create_native_write_table
+from pypaimon.write.native_commit import (
+    create_native_write_table, from_native_commit_messages,
+)
 from pypaimon.write.row_utils import row_to_named_values, 
row_values_to_arrow_table
 
 
@@ -39,6 +39,14 @@ def native_write_available() -> bool:
     return True
 
 
+def _native_partition_types_supported(schema, partition_keys):
+    """Partition keys which Rust can encode and use to locate existing 
files."""
+    return not any(
+        pa.types.is_binary(data_type) or pa.types.is_large_binary(data_type)
+        or pa.types.is_fixed_size_binary(data_type) or 
pa.types.is_floating(data_type)
+        for data_type in (schema.field(name).type for name in partition_keys))
+
+
 def create_native_write(table, commit_user, static_partition=None, 
stream=False):
     """Return a native writer if the table can use the filesystem write 
path."""
     schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
@@ -49,7 +57,6 @@ def create_native_write(table, commit_user, 
static_partition=None, stream=False)
         if (not table.options.sequence_field_sort_order_is_ascending()
                 or any(pa.types.is_floating(schema.field(name).type) for name 
in sequence_fields)):
             return None
-    partition_types = [schema.field(name).type for name in 
table.partition_keys]
     if (not native_write_available()
             or table.options.data_evolution_enabled()
             or table.options.data_file_external_paths()
@@ -69,8 +76,7 @@ def create_native_write(table, commit_user, 
static_partition=None, stream=False)
             or any(pa.types.is_nested(field.type)
                    or pa.types.is_fixed_size_binary(field.type) for field in 
schema)
             # Rust cannot encode these partition keys yet.
-            or any(pa.types.is_binary(type_) or pa.types.is_large_binary(type_)
-                   or pa.types.is_floating(type_) for type_ in partition_types)
+            or not _native_partition_types_supported(schema, 
table.partition_keys)
             or any(is_blob_file_field(field) for field in 
table.table_schema.fields)):
         return None
     native_table = create_native_write_table(table)
@@ -179,14 +185,7 @@ class NativeTableWrite:
             if commit_identifier is not None:
                 raise TypeError('BatchTableWrite.prepare_commit accepts no 
identifier')
             messages = self._native_writer.prepare_commit()
-        decoded = [deserialize_commit_message(
-            message.serialize(), self.table.partition_keys_fields,
-            self.table.trimmed_primary_keys_fields) for message in messages]
-        for message in decoded:
-            for file in message.new_files + message.changelog_files:
-                file.file_path = file.external_path or 
canonical_data_file_path(
-                    self.table, message.partition, message.bucket, 
file.file_name)
-        return decoded
+        return from_native_commit_messages(self.table, messages)
 
     def close(self):
         if self._python_writer is not None:
diff --git a/paimon-python/pypaimon/write/table_update.py 
b/paimon-python/pypaimon/write/table_update.py
index 494127d791..5c5958cb1e 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -16,6 +16,7 @@
 # under the License.
 
 from collections import defaultdict
+import logging
 from typing import Any, Iterable, List, Mapping, Optional, Sequence, Tuple
 
 import pyarrow
@@ -36,6 +37,7 @@ from pypaimon.manifest.index_manifest_file import 
IndexManifestFile
 from pypaimon.manifest.manifest_list_manager import ManifestListManager
 from pypaimon.manifest.schema.data_file_meta import DataFileMeta
 from pypaimon.read.scanner.file_scanner import FileScanner
+from pypaimon.read.query_auth_split import QueryAuthSplit
 from pypaimon.read.scanner.data_evolution_split_generator import (
     DataEvolutionSplitGenerator,
 )
@@ -53,6 +55,8 @@ from pypaimon.write.table_upsert_by_key import 
TableUpsertByKey
 from pypaimon.write.writer.data_writer import DataWriter
 from pypaimon.write.writer.append_only_data_writer import AppendOnlyDataWriter
 
+logger = logging.getLogger(__name__)
+
 
 def _filter_by_whole_file_shard(splits: List[DataSplit], sub_task_id: int, 
total_tasks: int) -> List[DataSplit]:
     list_ranges = []
@@ -129,6 +133,16 @@ class TableUpdate:
     def _new_row_id_updater(
             self, commit_identifier: int, _precomputed_files_info=None
     ) -> TableUpdateByRowId:
+        if _precomputed_files_info is None:
+            from pypaimon.write.native_update import 
create_native_update_by_row_id
+            try:
+                native = create_native_update_by_row_id(
+                    self.table, self.commit_user, commit_identifier)
+            except Exception as error:
+                logger.debug('Native row-id updater preparation failed: %s', 
error)
+            else:
+                if native is not None:
+                    return native
         return TableUpdateByRowId(
             self.table,
             self.commit_user,
@@ -174,6 +188,16 @@ class TableUpdate:
         cols = self.update_cols if self.update_cols is not None else [
             c for c in table.column_names if c != SpecialFields.ROW_ID.name
         ]
+        if (table.num_rows and cols
+                and SpecialFields.ROW_ID.name in table.column_names):
+            try:
+                from pypaimon.write.native_update import create_native_update
+                native = create_native_update(self.table, self.commit_user, 
cols)
+            except Exception as error:
+                logger.debug('Native update preparation failed; using Python: 
%s', error)
+            else:
+                if native is not None:
+                    return native.update_by_arrow_with_row_id(table)
         return self._new_row_id_updater(commit_identifier).update_columns(
             table, cols)
 
@@ -278,6 +302,17 @@ class TableUpdate:
             assignments, read_columns, has_callable, has_array
         )
 
+        if self.table.options.native_write_enabled():
+            from pypaimon.write.native_update import 
create_native_predicate_update
+            try:
+                native = create_native_predicate_update(
+                    self.table, self.commit_user, list(assignments.keys()), 
predicate)
+            except Exception as error:
+                logger.debug('Native predicate update preparation failed: %s', 
error)
+            else:
+                if native is not None:
+                    return native.update(assignments, read_columns)
+
         scan_table = self._matched_update_scan_table()
         read_builder = scan_table.new_read_builder()
         if predicate is not None:
@@ -509,7 +544,7 @@ class TableUpdate:
             return self._delete_by_partition_filter(partition_filter)
 
         row_ids = self._matched_delete_row_ids(predicate)
-        return TableDeleteByRowId(self.table).delete(row_ids)
+        return self._delete_by_row_id(row_ids, commit_identifier)
 
     def _delete_by_partition_filter(
             self, partition_filter: Predicate) -> List[CommitMessage]:
@@ -576,6 +611,15 @@ class TableUpdate:
             row_ids: Sequence[int],
             commit_identifier: int,
     ) -> List[CommitMessage]:
+        if len(row_ids):
+            try:
+                from pypaimon.write.native_update import create_native_delete
+                native = create_native_delete(self.table, self.commit_user)
+            except Exception as error:
+                logger.debug('Native delete preparation failed; using Python: 
%s', error)
+            else:
+                if native is not None:
+                    return native.delete_by_row_id(row_ids)
         return TableDeleteByRowId(self.table).delete(list(row_ids))
 
     def _partition_only_delete_filter(
@@ -637,6 +681,19 @@ class TableUpdate:
 
         scan = read_builder.new_scan()
         splits = scan.plan_for_write().splits()
+        if (splits
+                and self.table.options.native_write_enabled()
+                and self.table.options.data_file_path_directory() is None
+                and not any(isinstance(split, QueryAuthSplit)
+                            for split in splits)):
+            try:
+                from pypaimon.write.native_update import 
native_predicate_row_ids
+                row_ids = native_predicate_row_ids(scan_table, predicate, 
splits)
+            except Exception as error:
+                logger.debug('Native predicate delete match failed: %s', error)
+            else:
+                if row_ids is not None:
+                    return row_ids
         matched = read_builder.new_read().to_arrow(splits)
         if matched.num_rows == 0:
             return []
@@ -666,6 +723,15 @@ class BatchTableUpdate(TableUpdate):
         file groups. Conflicting overlap is rejected and all files staged by
         earlier batches are aborted.
         """
+        try:
+            from pypaimon.write.native_update import create_native_update
+            native = create_native_update(
+                self.table, self.commit_user, self.update_cols)
+        except Exception as error:
+            logger.debug('Native batch update preparation failed: %s', error)
+        else:
+            if native is not None:
+                return native.update_by_arrow_batches_with_row_id(tables)
         return self._update_by_arrow_batches_with_row_id(
             tables, BATCH_COMMIT_IDENTIFIER)
 
@@ -710,6 +776,11 @@ class BatchTableUpdate(TableUpdate):
         """Delete rows by ``_ROW_ID`` using deletion vectors."""
         return self._delete_by_row_id(row_ids, BATCH_COMMIT_IDENTIFIER)
 
+    def _delete_by_row_id(
+            self, row_ids: Sequence[int], commit_identifier: int
+    ) -> List[CommitMessage]:
+        return super()._delete_by_row_id(row_ids, commit_identifier)
+
     def merge_into(
             self,
             source: Any,
diff --git a/paimon-python/pypaimon/write/table_update_by_row_id.py 
b/paimon-python/pypaimon/write/table_update_by_row_id.py
index a8af846c9c..b1c65a1ed7 100644
--- a/paimon-python/pypaimon/write/table_update_by_row_id.py
+++ b/paimon-python/pypaimon/write/table_update_by_row_id.py
@@ -355,6 +355,9 @@ class TableUpdateByRowId:
             fields.append(arrow_field)
 
         update_data = pa.Table.from_arrays(arrays, schema=pa.schema(fields))
+        return self._write_row_columns(update_data, column_names, 
blob_object_columns)
+
+    def _write_row_columns(self, update_data, column_names, 
blob_object_columns):
         data_with_first_row_id = self._calculate_first_row_id(update_data)
         self._write_by_first_row_id(
             data_with_first_row_id, column_names, blob_object_columns)
diff --git a/paimon-python/pypaimon/write/table_upsert_by_key.py 
b/paimon-python/pypaimon/write/table_upsert_by_key.py
index aebbdf6176..3ffb6ad30e 100644
--- a/paimon-python/pypaimon/write/table_upsert_by_key.py
+++ b/paimon-python/pypaimon/write/table_upsert_by_key.py
@@ -94,6 +94,12 @@ class TableUpsertByKey:
         else:
             effective_update_cols = update_cols
 
+        columns = (list(effective_update_cols) if effective_update_cols
+                   else list(self.table.field_names))
+        native = self._create_native_upsert(data, upsert_keys, columns)
+        if native is not None:
+            return native.upsert()
+
         all_commit_messages: List[CommitMessage] = []
 
         # Process each partition independently
@@ -123,6 +129,13 @@ class TableUpsertByKey:
         else:
             effective_update_cols = update_cols
 
+        columns = (list(effective_update_cols) if effective_update_cols is not 
None
+                   else list(self.table.field_names))
+        native = self._create_native_upsert(
+            [values for _, values in row_items], upsert_keys, columns)
+        if native is not None:
+            return native.upsert()
+
         commit_messages: List[CommitMessage] = []
         for partition_spec, partition_items in 
self._group_rows_by_partition(row_items):
             commit_messages.extend(
@@ -135,6 +148,16 @@ class TableUpsertByKey:
             )
         return commit_messages
 
+    def _create_native_upsert(self, data, upsert_keys, columns):
+        try:
+            from pypaimon.write.native_update import create_native_upsert
+            return create_native_upsert(
+                self.table, self.commit_user, data, upsert_keys, columns)
+        except Exception as error:
+            logger.debug(
+                'Native upsert preparation failed; using Python: %s', error)
+            return None
+
     @staticmethod
     def _normalize_rows(rows) -> List:
         if isinstance(rows, InternalRow):

Reply via email to