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 5a6739c6c1 [Python] Align native mutations, partial writes and commit 
semantics (#10648)
5a6739c6c1 is described below

commit 5a6739c6c10e96052bac678eb245742ad92bc556
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 19 16:28:16 2026 +0800

    [Python] Align native mutations, partial writes and commit semantics 
(#10648)
---
 docs/docs/pypaimon/writing.md                      |  18 +-
 .../pypaimon/manifest/index_manifest_file.py       |  29 +-
 .../pypaimon/table/data_evolution_merge_into.py    |   4 +
 .../pypaimon/tests/index_manifest_write_test.py    |  28 +-
 .../tests/native_authorized_update_test.py         | 584 +++++++++++++++++++++
 .../pypaimon/tests/native_commit_semantics_test.py | 244 +++++++++
 .../tests/native_overwrite_indexes_test.py         | 357 +++++++++++++
 .../tests/native_upsert_encoded_keys_test.py       | 228 ++++++++
 .../pypaimon/tests/native_write_routing_test.py    |  21 +-
 .../pypaimon/tests/native_write_type_test.py       | 254 +++++++++
 .../pypaimon/tests/write/table_write_test.py       |   6 +-
 .../write/commit/overwrite_changes_provider.py     |  42 +-
 paimon-python/pypaimon/write/file_store_commit.py  |  70 +--
 paimon-python/pypaimon/write/native_merge_into.py  |   5 +-
 paimon-python/pypaimon/write/native_update.py      |  23 +-
 paimon-python/pypaimon/write/native_write.py       |  27 +-
 paimon-python/pypaimon/write/table_update.py       |  11 +
 paimon-python/pypaimon/write/table_write.py        |  15 +-
 18 files changed, 1879 insertions(+), 87 deletions(-)

diff --git a/docs/docs/pypaimon/writing.md b/docs/docs/pypaimon/writing.md
index c74deca7c8..33144b6dd4 100644
--- a/docs/docs/pypaimon/writing.md
+++ b/docs/docs/pypaimon/writing.md
@@ -79,12 +79,20 @@ to use Rust for Parquet/BLOB writes and 
`commit.native.enabled=true` to use
 Rust for REST snapshot publication. Batch and stream writers retain the same
 prepare-and-commit lifecycle. Native commits also support overwrite,
 `truncate_table()`, `truncate_partitions()` and commit callbacks.
+Overwrite and truncate record an OVERWRITE snapshot even when no files match,
+and remove index metadata for the replaced partitions. Physical files remain
+available to historical snapshots. Changelog-only commits also publish a 
snapshot.
 
 Standard REST tables with `query-auth.enabled=true` can write and commit
 without requesting SELECT permission. The REST server still authorizes snapshot
 publication. Row filters and column masks continue to apply to explicit reads;
 internal sequence-number and HASH-index restoration reads manifest metadata.
-Predicate updates and deletes retain their existing read authorization checks.
+For query-authorized targets, Native row-ID updates, predicate updates/deletes,
+upserts, shard updates and MERGE require an unrestricted whole-table SELECT 
grant
+before reading target rows or evaluating assignments. A row filter, column mask
+or denied grant rejects the operation; ordinary writes still require no SELECT
+grant. MERGE table sources obtain their own grants independently from the 
target,
+including historical snapshots and branches.
 Query-authorized cross-partition index bootstrap remains unsupported.
 
 `write_ray()` uses the shared Python coordinator and lets workers select Native
@@ -93,6 +101,14 @@ unless an explicit bucket plan was supplied. A complete 
primary-key
 `with_write_type()` leaves the writer unchanged; primary-key column subsets
 remain unsupported.
 
+Append `with_write_type()` preserves the selected column order. Both the full
+table schema and the selected schema are accepted as Arrow input; only selected
+columns are written. Full Arrow inputs and named Rows can carry partition and
+hash columns used only for routing, independently of the physical write type.
+Reads restore omitted partition columns from split metadata. Selected-schema
+inputs must include the routing columns they need; `write_arrow(data, 
bucket=...)`
+also accepts a bucket assigned upstream.
+
 Native commit selection requires a standard REST catalog and a reconstructible
 built-in FileIO. Other catalogs and custom Python FileIO implementations retain
 the Python path. Binary partition keys require `partition.legacy-name=false` 
for
diff --git a/paimon-python/pypaimon/manifest/index_manifest_file.py 
b/paimon-python/pypaimon/manifest/index_manifest_file.py
index ee78f08d4a..a19c196f50 100644
--- a/paimon-python/pypaimon/manifest/index_manifest_file.py
+++ b/paimon-python/pypaimon/manifest/index_manifest_file.py
@@ -252,8 +252,10 @@ class IndexManifestFile:
         if not adds and not deletes:
             return previous_name
         previous = self.read(previous_name) if previous_name else []
-        delete_names = {e.index_file.file_name for e in deletes}
-        survivors = [e for e in previous if e.index_file.file_name not in 
delete_names]
+        _validate_global_index_deletes(previous, deletes)
+        delete_files = {(e.index_file.index_type, e.index_file.file_name) for 
e in deletes}
+        survivors = [e for e in previous
+                     if (e.index_file.index_type, e.index_file.file_name) not 
in delete_files]
         _validate_retained_global_index_files(survivors, adds)
         combined = _combine_hash_indexes(survivors, adds, deletes)
         if not combined:
@@ -313,6 +315,29 @@ class IndexManifestFile:
         }
 
 
+def _validate_global_index_deletes(previous_entries, deleted_entries) -> None:
+    # Java combines global indexes by index type and file name. Verify each
+    # removal against the current entry, including metadata-only reassignment.
+    # HASH and DV keep their existing bucket/index-specific deletion rules.
+    current = {(entry.index_file.index_type, entry.index_file.file_name): entry
+               for entry in previous_entries if 
_is_global_index(entry.index_file.index_type)}
+    for deleted in deleted_entries:
+        meta = deleted.index_file
+        if not _is_global_index(meta.index_type):
+            continue
+        previous = current.pop((meta.index_type, meta.file_name), None)
+        if previous is None:
+            raise RuntimeError(
+                'Trying to delete global index file %s which does not exist.' 
% meta.file_name)
+        previous_meta = previous.index_file
+        if (previous.partition != deleted.partition or previous.bucket != 
deleted.bucket
+                or previous_meta != meta or previous_meta.dv_ranges != 
meta.dv_ranges
+                or previous_meta.external_path != meta.external_path
+                or previous_meta.global_index_meta != meta.global_index_meta):
+            raise RuntimeError(
+                'Index file %s metadata does not match the current entry.' % 
meta.file_name)
+
+
 def _validate_retained_global_index_files(
     retained_entries: List[IndexManifestEntry],
     added_entries: List[IndexManifestEntry],
diff --git a/paimon-python/pypaimon/table/data_evolution_merge_into.py 
b/paimon-python/pypaimon/table/data_evolution_merge_into.py
index d64294af60..91ca996634 100644
--- a/paimon-python/pypaimon/table/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/table/data_evolution_merge_into.py
@@ -106,6 +106,10 @@ def merge_into(
         if native is not None:
             return native.prepare_commit()
 
+    # Empty-target INSERT skips the target scan, but still needs the same
+    # unrestricted whole-table grant before any user callback or staging.
+    from pypaimon.write.table_update import _authorize_update
+    _authorize_update(target_table)
     base_snapshot = target_table.snapshot_manager().get_latest_snapshot()
     source_table, matched_specs, not_matched_specs, ctx = _prepare(
         target_table,
diff --git a/paimon-python/pypaimon/tests/index_manifest_write_test.py 
b/paimon-python/pypaimon/tests/index_manifest_write_test.py
index e6e428f6b5..b3ec41b500 100644
--- a/paimon-python/pypaimon/tests/index_manifest_write_test.py
+++ b/paimon-python/pypaimon/tests/index_manifest_write_test.py
@@ -109,12 +109,32 @@ class IndexManifestWriteTest(unittest.TestCase):
         survivors = {e.index_file.file_name for e in imf.read(new_name)}
         self.assertEqual({'idx-b'}, survivors)
 
-    def test_combine_unknown_delete_is_noop_on_content(self):
+    def test_combine_unknown_global_index_delete_is_rejected(self):
         imf = IndexManifestFile(self._table())
         previous = imf.write([self._entry('idx-a', 1)])
-        new_name = imf.combine_deletes(previous, [self._entry('idx-zzz', 9)])
-        survivors = {e.index_file.file_name for e in imf.read(new_name)}
-        self.assertEqual({'idx-a'}, survivors)
+        with self.assertRaisesRegex(RuntimeError, 'does not exist'):
+            imf.combine_deletes(previous, [self._entry('idx-zzz', 9)])
+        self.assertEqual(['idx-a'], [e.index_file.file_name for e in 
imf.read(previous)])
+
+    def 
test_combine_global_index_delete_retains_other_type_with_same_name(self):
+        imf = IndexManifestFile(self._table())
+        btree = self._entry('shared-name', 1)
+        bitmap = self._entry('shared-name', 1)
+        bitmap.index_file.index_type = 'bitmap'
+        previous = imf.write([btree, bitmap])
+        current = imf.combine_deletes(previous, [btree])
+        self.assertEqual(['bitmap'], [e.index_file.index_type for e in 
imf.read(current)])
+
+    def 
test_combine_hash_index_delete_keeps_bucketed_stale_delete_semantics(self):
+        imf = IndexManifestFile(self._table())
+        current = self._entry('current-hash', 1)
+        current.index_file.index_type = 'HASH'
+        current.index_file.global_index_meta = None
+        previous = imf.write([current])
+        stale = self._entry('old-hash', 1)
+        stale.index_file.index_type = 'HASH'
+        stale.index_file.global_index_meta = None
+        self.assertIsNone(imf.combine_deletes(previous, [stale]))
 
     def test_combine_empty_deletes_returns_previous(self):
         imf = IndexManifestFile(self._table())
diff --git a/paimon-python/pypaimon/tests/native_authorized_update_test.py 
b/paimon-python/pypaimon/tests/native_authorized_update_test.py
new file mode 100644
index 0000000000..1f9c95d02a
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_authorized_update_test.py
@@ -0,0 +1,584 @@
+# 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.
+
+"""Read-based Native mutations require unrestricted REST table 
authorization."""
+
+import json
+from contextlib import ExitStack
+from pathlib import Path
+from urllib.parse import unquote, urlparse
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pyarrow.compute as pc
+import pytest
+
+from pypaimon import Schema
+from pypaimon.api.api_response import AuthTableQueryResponse, ErrorResponse
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.table.data_evolution_merge_into import WhenMatched, 
WhenNotMatched, source_col
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.tests import native_plan_rest_test
+from pypaimon.write.native_update import NativeShardTableUpdator, 
_native_row_id_table
+
+
+rest_catalog = native_plan_rest_test.rest_catalog
+pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
+native = pytest.mark.skipif(not native_runtime_available(), 
reason='pypaimon-rust not installed')
+_ENGINES = [False, pytest.param(True, marks=native)]
+_SCHEMA = pa.schema([('id', pa.int32()), ('value', pa.int32()), ('name', 
pa.string())])
+_ROWS = [dict(id=1, value=10, name='a'), dict(id=2, value=20, name='b'), 
dict(id=3, value=30, name='c')]
+_OPERATIONS = ['row-id', 'grouped', 'incremental', 'predicate', 'upsert', 
'row-upsert',
+               'row-delete', 'predicate-delete', 'shard', 'merge']
+
+
+def _files(table, pattern='*'):
+    root = Path(unquote(urlparse(table.table_path).path))
+    assert root.is_dir(), table.table_path
+    return set(root.rglob(pattern))
+
+
+def _append(table, rows):
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.Table.from_pylist(rows, schema=_SCHEMA))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+
+
+def _table(catalog, use_native=False, deletion_vectors=True, 
partitioned=False, seed=True):
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        _SCHEMA, partition_keys=['name'] if partitioned else [], options={
+            'query-auth.enabled': 'true', 'row-tracking.enabled': 'true', 
'data-evolution.enabled': 'true',
+            'deletion-vectors.enabled': str(deletion_vectors).lower(),
+            'write.native.enabled': 'false', 'commit.native.enabled': 'false',
+            'read.native.enabled': 'false', 'scan.native-plan.enabled': 
'false',
+        }), False)
+    table = catalog.get_table('default.t')
+    # Separate anchor files permit grouped updates of the same column.
+    if seed:
+        _append(table, _ROWS[:2])
+        _append(table, _ROWS[2:])
+    return table.copy({'write.native.enabled': str(use_native).lower()})
+
+
+def _rows(table):
+    builder = table.new_read_builder()
+    return 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by('id').to_pylist()
+
+
+def _native_only(stack, use_native):
+    if use_native:
+        for method in (
+                
'pypaimon.write.table_update_by_row_id.TableUpdateByRowId._load_existing_files_info',
+                
'pypaimon.write.table_upsert_by_key.TableUpsertByKey._upsert_partition',
+                
'pypaimon.write.table_delete.TableDeleteByRowId._scan_anchor_ranges',
+                'pypaimon.write.table_update.ShardTableUpdator',
+                'pypaimon.table.data_evolution_merge_into._build_tables',
+                'pypaimon.read.table_read.TableRead.to_arrow'):
+            stack.enter_context(patch(method, 
side_effect=AssertionError('Python mutation fallback')))
+
+
+def _operate(update, operation, stream=False, assignment=None):
+    kwargs = {'commit_identifier': 27} if stream else {}
+    if operation == 'row-id':
+        return update.update_by_arrow_with_row_id(pa.table({'_ROW_ID': [0], 
'value': [11]}), **kwargs)
+    if operation == 'grouped':
+        return update.update_by_arrow_batches_with_row_id(iter([
+            pa.table({'_ROW_ID': [0], 'value': [11]}), pa.table({'_ROW_ID': 
[2], 'value': [33]})]))
+    if operation == 'incremental':
+        updater = update.new_update_by_row_id(**kwargs)
+        updater.update_columns(pa.table({'_ROW_ID': [0], 'value': [11]}), 
['value'])
+        return updater.update_columns(pa.table({'_ROW_ID': [1], 'name': 
['B']}), ['name'])
+    predicate = update.new_predicate_builder().equal('id', 1)
+    if operation == 'predicate':
+        assignment = assignment or (lambda batch: pc.add(batch['value'], 1))
+        return update.update_by_predicate(predicate, {'value': assignment}, 
read_columns=['value'], **kwargs)
+    if operation in ('upsert', 'row-upsert'):
+        rows = [dict(id=1, value=11, name='A'), dict(id=4, value=40, name='d')]
+        if operation == 'row-upsert':
+            return update.upsert_by_key([
+                GenericRow([row['id'], row['value'], row['name']], 
update.table.fields) for row in rows
+            ], ['id'], **kwargs)
+        return update.upsert_by_arrow_with_key(pa.Table.from_pylist(rows, 
schema=_SCHEMA), ['id'], **kwargs)
+    if operation == 'row-delete':
+        return update.delete_by_row_id([0, 0], **kwargs)
+    if operation == 'predicate-delete':
+        return update.delete_by_predicate(predicate, **kwargs)
+    if operation == 'shard':
+        update.with_update_type(['value'])
+        update.with_read_projection(['id'])
+        updater = update.new_shard_updator(0, 1)
+        try:
+            with updater.arrow_reader() as reader:
+                for batch in reader:
+                    
updater.update_by_arrow_batch(pa.record_batch([pc.multiply(batch['id'], 100)], 
names=['value']))
+            return updater.prepare_commit()
+        finally:
+            if isinstance(updater, NativeShardTableUpdator):
+                updater.close()
+    if operation == 'merge':
+        source = pa.Table.from_pylist([dict(id=1, value=11, name='A'), 
dict(id=4, value=40, name='d')],
+                                      schema=_SCHEMA)
+        assignment = assignment or (lambda batch: pc.add(batch['s.value'], 1))
+        return update.merge_into(source, on=['id'], 
when_matched=[WhenMatched.update({'value': assignment})],
+                                 when_not_matched=[WhenNotMatched('*')], 
**kwargs)
+    raise AssertionError(operation)
+
+
+def _expected(operation):
+    rows = [dict(row) for row in _ROWS]
+    if operation in ('row-delete', 'predicate-delete'):
+        return rows[1:]
+    if operation == 'shard':
+        return [dict(row, value=row['id'] * 100) for row in rows]
+    rows[0]['value'] = 12 if operation == 'merge' else 11
+    if operation == 'grouped':
+        rows[2]['value'] = 33
+    if operation == 'incremental':
+        rows[1]['name'] = 'B'
+    if operation in ('upsert', 'row-upsert', 'merge'):
+        rows.append(dict(id=4, value=40, name='d'))
+        if operation != 'merge':
+            rows[0]['name'] = 'A'
+    return rows
+
+
[email protected]('use_native', _ENGINES)
[email protected]('operation', _OPERATIONS)
+def test_unrestricted_rest_updates_share_python_semantics(rest_catalog, 
use_native, operation):
+    catalog, server = rest_catalog
+    table = _table(catalog, use_native)
+    builder = table.new_batch_write_builder()
+    with patch.object(server, '_table_query_auth_handle', 
wraps=server._table_query_auth_handle) as authorize:
+        with ExitStack() as stack:
+            _native_only(stack, use_native)
+            messages = _operate(builder.new_update(), operation)
+        assert messages
+        builder.new_commit().commit(messages)
+        assert authorize.call_count >= 1
+        assert any(json.loads(call.args[1]).get('select') is None for call in 
authorize.call_args_list)
+    assert _rows(table) == _expected(operation)
+
+
[email protected]('use_native', _ENGINES)
[email protected]('operation', ['update', 'delete'])
[email protected]('policy', ['whole', 'denied', 'filter', 'mask'])
[email protected]('metadata', ['_ROW_ID', '_SEQUENCE_NUMBER'])
+def test_metadata_predicate_mutations_require_whole_table_authorization(
+        rest_catalog, use_native, operation, policy, metadata):
+    catalog, server = rest_catalog
+    table = _table(catalog, use_native)
+    read = table.new_read_builder().with_projection(_SCHEMA.names + [metadata])
+    predicate = read.new_predicate_builder().equal(metadata, 0 if metadata == 
'_ROW_ID' else 1)
+    builder = table.new_batch_write_builder()
+    assignment = Mock(side_effect=lambda batch: pc.add(batch['value'], 1))
+    before = _files(table)
+    snapshot_id = table.snapshot_manager().get_latest_snapshot().id
+
+    def mutate():
+        update = builder.new_update()
+        if operation == 'delete':
+            return update.delete_by_predicate(predicate)
+        return update.update_by_predicate(predicate, {'value': assignment}, 
read_columns=['value'])
+
+    with ExitStack() as stack:
+        authorize = stack.enter_context(
+            patch.object(server, '_table_query_auth_handle', 
wraps=server._table_query_auth_handle)
+            if policy == 'whole' else _restricted(server, table, policy))
+        _native_only(stack, use_native)
+        if policy == 'whole':
+            messages = mutate()
+            assert messages
+        else:
+            with pytest.raises(Exception, 
match='[Pp]ermission|denied|unrestricted|restrict|[Mm]ask|query.auth'):
+                mutate()
+            assignment.assert_not_called()
+            assert _files(table) == before
+            assert table.snapshot_manager().get_latest_snapshot().id == 
snapshot_id
+        assert authorize.call_count >= 1
+        assert all(json.loads(call.args[1]).get('select') is None for call in 
authorize.call_args_list)
+    if policy == 'whole':
+        assert assignment.call_count == (1 if operation == 'update' else 0)
+        builder.new_commit().commit(messages)
+        matched = 1 if metadata == '_ROW_ID' else 2
+        expected = ([dict(row, value=row['value'] + 1) for row in 
_ROWS[:matched]] + _ROWS[matched:]
+                    if operation == 'update' else _ROWS[matched:])
+        assert _rows(table) == expected
+
+
[email protected]('use_native', _ENGINES)
[email protected]('operation', ['row-id', 'incremental', 'predicate', 
'upsert', 'row-delete', 'merge'])
+def test_stream_rest_updates_keep_commit_identifier(rest_catalog, use_native, 
operation):
+    catalog, _ = rest_catalog
+    table = _table(catalog, use_native)
+    builder = table.new_stream_write_builder()
+    with ExitStack() as stack:
+        _native_only(stack, use_native)
+        messages = _operate(builder.new_update(), operation, stream=True)
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 27)
+    finally:
+        commit.close()
+    assert table.snapshot_manager().get_latest_snapshot().commit_identifier == 
27
+    assert _rows(table) == _expected(operation)
+
+
+def _restricted(server, table, policy):
+    if policy == 'denied':
+        return patch.object(server, '_table_query_auth_handle', 
return_value=server._mock_response(
+            ErrorResponse('TABLE', 't', 'SELECT denied', 403), 403))
+    field = {'name': 'id', 'type': 'INT', 'index': 0}
+    response = (AuthTableQueryResponse(filter=[json.dumps({
+        'kind': 'LEAF', 'transform': {'name': 'FIELD_REF', 'fieldRef': field},
+        'function': 'EQUAL', 'literals': [1]})]) if policy == 'filter' else
+        AuthTableQueryResponse(column_masking={'name': json.dumps({'name': 
'CONCAT', 'inputs': ['masked']})}))
+    server.table_query_auth_store[table.identifier.get_full_name()] = response
+    return patch.object(server, '_table_query_auth_handle', 
wraps=server._table_query_auth_handle)
+
+
[email protected]('use_native', _ENGINES)
[email protected]('policy', ['denied', 'filter', 'mask'])
[email protected]('operation', _OPERATIONS)
+def test_restricted_target_refuses_before_callable_or_staging(rest_catalog, 
use_native, policy, operation):
+    catalog, server = rest_catalog
+    table = _table(catalog, use_native)
+    before = _files(table)
+    snapshot_id = table.snapshot_manager().get_latest_snapshot().id
+    assignment = Mock(side_effect=AssertionError('assignment ran before 
authorization'))
+    with _restricted(server, table, policy) as authorize:
+        with ExitStack() as stack:
+            _native_only(stack, use_native)
+            with pytest.raises(Exception, 
match='[Pp]ermission|denied|unrestricted|restrict|[Mm]ask|query.auth'):
+                _operate(table.new_batch_write_builder().new_update(), 
operation, assignment=assignment)
+        assert authorize.call_count >= 1
+    assignment.assert_not_called()
+    assert _files(table) == before
+    assert table.snapshot_manager().get_latest_snapshot().id == snapshot_id
+
+
[email protected]('use_native', _ENGINES)
[email protected]('operation', ['row-delete', 'predicate-delete'])
+def test_authorized_delete_still_requires_deletion_vectors(rest_catalog, 
use_native, operation):
+    catalog, _ = rest_catalog
+    table = _table(catalog, use_native, deletion_vectors=False)
+    before = _files(table)
+    with pytest.raises(NotImplementedError, match='deletion-vectors.enabled'):
+        _operate(table.new_batch_write_builder().new_update(), operation)
+    assert _files(table) == before
+
+
+def 
test_guarded_native_update_resolves_the_rest_table_instead_of_switching_to_python(rest_catalog):
+    catalog, _ = rest_catalog
+    table = _table(catalog, True)
+    resolved = object()
+    with patch('pypaimon.write.native_update.native_runtime_available', 
return_value=True), \
+            patch('pypaimon.write.native_update.create_native_write_table', 
return_value=resolved) as create:
+        assert _native_row_id_table(table) is resolved
+    create.assert_called_once_with(table)
+
+
[email protected]('use_native', _ENGINES)
[email protected]('policy', ['whole', 'denied', 'filter', 'mask'])
+def 
test_partition_delete_optimization_keeps_whole_table_authorization(rest_catalog,
 use_native, policy):
+    catalog, server = rest_catalog
+    table = _table(catalog, use_native, partitioned=True)
+    builder = table.new_batch_write_builder()
+    predicate = builder.new_update().new_predicate_builder().equal('name', 'a')
+    before = _files(table)
+    with ExitStack() as stack:
+        authorize = stack.enter_context(
+            patch.object(server, '_table_query_auth_handle', 
wraps=server._table_query_auth_handle)
+            if policy == 'whole' else _restricted(server, table, policy))
+        _native_only(stack, use_native)
+        if policy == 'whole':
+            messages = builder.new_update().delete_by_predicate(predicate)
+            assert messages
+        else:
+            with pytest.raises(Exception, 
match='[Pp]ermission|denied|unrestricted|restrict|[Mm]ask|query.auth'):
+                builder.new_update().delete_by_predicate(predicate)
+            assert _files(table) == before
+        assert authorize.call_count >= 1
+    if policy == 'whole':
+        builder.new_commit().commit(messages)
+        assert _rows(table) == _ROWS[1:]
+
+
[email protected]('use_native', _ENGINES)
[email protected]('policy', ['denied', 'filter', 'mask'])
+def test_empty_target_merge_authorizes_before_insert_callable(rest_catalog, 
use_native, policy):
+    catalog, server = rest_catalog
+    table = _table(catalog, use_native, seed=False)
+    assignment = Mock(side_effect=AssertionError('insert ran before target 
authorization'))
+    source = pa.Table.from_pylist(_ROWS[:1], schema=_SCHEMA)
+    before = _files(table)
+    with _restricted(server, table, policy) as authorize:
+        with ExitStack() as stack:
+            _native_only(stack, use_native)
+            with pytest.raises(Exception, 
match='[Pp]ermission|denied|unrestricted|restrict|[Mm]ask|query.auth'):
+                table.new_batch_write_builder().new_update().merge_into(
+                    source, on=['id'], when_not_matched=[
+                        WhenNotMatched({'value': assignment, 'name': 
'insert'})])
+        assert authorize.call_count >= 1
+    assignment.assert_not_called()
+    assert _files(table) == before
+    assert table.snapshot_manager().get_latest_snapshot() is None
+
+
+def test_custom_query_authorization_stays_on_python_path(rest_catalog):
+    catalog, _ = rest_catalog
+    table = _table(catalog, True)
+    custom_auth = Mock()
+    with patch.object(table.catalog_environment, 'table_query_auth', 
return_value=custom_auth), \
+            patch('pypaimon.write.native_update.native_runtime_available', 
return_value=True), \
+            patch('pypaimon.write.native_update.create_native_write_table') as 
create:
+        assert _native_row_id_table(table) is None
+    create.assert_not_called()
+    custom_auth.assert_not_called()
+
+
[email protected]('use_native', _ENGINES)
+def 
test_unrestricted_empty_target_merge_evaluates_insert_in_rust_or_python(rest_catalog,
 use_native):
+    catalog, _ = rest_catalog
+    table = _table(catalog, use_native, seed=False)
+    builder = table.new_batch_write_builder()
+    source = pa.Table.from_pylist(_ROWS[:1], schema=_SCHEMA)
+    assignment = Mock(side_effect=lambda batch: pc.add(batch['s.value'], 1))
+    with ExitStack() as stack:
+        _native_only(stack, use_native)
+        messages = builder.new_update().merge_into(
+            source, on=['id'], when_not_matched=[WhenNotMatched({'value': 
assignment, 'name': source_col('name')})])
+    assert assignment.call_count == 1
+    builder.new_commit().commit(messages)
+    assert _rows(table) == [dict(id=1, value=11, name='a')]
+
+
[email protected]('use_native', _ENGINES)
[email protected]('restricted_side,policy', [
+    ('source', 'whole'), ('source', 'denied'), ('source', 'filter'), 
('source', 'mask'),
+    ('target', 'denied'), ('target', 'filter'), ('target', 'mask'),
+])
+def test_merge_keeps_source_and_target_grants_separate(rest_catalog, 
use_native, restricted_side, policy):
+    catalog, server = rest_catalog
+    table = _table(catalog, use_native)
+    catalog.create_table('default.source', Schema.from_pyarrow_schema(_SCHEMA, 
options={
+        'query-auth.enabled': 'true', 'write.native.enabled': 'false', 
'commit.native.enabled': 'false',
+        'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
+    }), False)
+    source = catalog.get_table('default.source')
+    _append(source, _ROWS[:1])
+    assignment = Mock(side_effect=lambda batch: pc.add(batch['s.value'], 1))
+    before = _files(table)
+    original = server._table_query_auth_handle
+    guarded = source if restricted_side == 'source' else table
+
+    def authorization(method, data, identifier):
+        if policy == 'denied' and identifier == guarded.identifier:
+            return server._mock_response(ErrorResponse('TABLE', 
restricted_side, 'SELECT denied', 403), 403)
+        return original(method, data, identifier)
+
+    builder = table.new_batch_write_builder()
+    with ExitStack() as stack:
+        if policy in ('filter', 'mask'):
+            stack.enter_context(_restricted(server, guarded, policy))
+        authorize = stack.enter_context(
+            patch.object(server, '_table_query_auth_handle', 
side_effect=authorization))
+        _native_only(stack, use_native)
+        if policy == 'whole':
+            messages = builder.new_update().merge_into(
+                source, on=['id'], when_matched=[WhenMatched.update({'value': 
assignment})])
+            assert messages
+        else:
+            with pytest.raises(Exception, 
match='[Pp]ermission|denied|unrestricted|restrict|[Mm]ask|query.auth'):
+                builder.new_update().merge_into(
+                    source, on=['id'], 
when_matched=[WhenMatched.update({'value': assignment})])
+            assignment.assert_not_called()
+            assert _files(table) == before
+        assert any(call.args[2] == guarded.identifier for call in 
authorize.call_args_list)
+    if policy == 'whole':
+        assert assignment.call_count == 1
+        builder.new_commit().commit(messages)
+        assert _rows(table) == [dict(id=1, value=11, name='a')] + _ROWS[1:]
+
+
[email protected]('use_native', _ENGINES)
+def 
test_authorized_update_keeps_physical_positions_after_deletion_vectors(rest_catalog,
 use_native):
+    catalog, _ = rest_catalog
+    table = _table(catalog, use_native)
+    # Rows 1 and 2 share an anchor; deleting the first physical slot must not
+    # shift the second slot when a column file is subsequently materialized.
+    for operation in ('delete', 'update'):
+        builder = table.new_batch_write_builder()
+        with ExitStack() as stack:
+            _native_only(stack, use_native)
+            update = builder.new_update()
+            messages = (update.delete_by_row_id([0]) if operation == 'delete' 
else
+                        
update.update_by_arrow_with_row_id(pa.table({'_ROW_ID': [1], 'value': [22]})))
+        builder.new_commit().commit(messages)
+    assert _rows(table) == [dict(id=2, value=22, name='b'), _ROWS[2]]
+
+
+def _blob_table(catalog, use_native):
+    from pypaimon.schema.data_types import AtomicType, DataField, 
PyarrowFieldParser
+
+    fields = Schema.from_pyarrow_schema(_SCHEMA).fields + [DataField(3, 
'payload', AtomicType('BLOB'))]
+    catalog.create_table('default.t', Schema(fields, options={
+        'query-auth.enabled': 'true', 'row-tracking.enabled': 'true', 
'data-evolution.enabled': 'true',
+        'write.native.enabled': 'false', 'commit.native.enabled': 'false',
+        'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
+        'blob.target-file-size': '32 B',
+    }), False)
+    table = catalog.get_table('default.t')
+    schema = PyarrowFieldParser.from_paimon_schema(fields)
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    original = [dict(row, payload=payload) for row, payload in zip(_ROWS, 
[b'a' * 40, b'b' * 60, None])]
+    try:
+        writer.write_arrow(pa.Table.from_pylist(original, schema=schema))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return table.copy({'write.native.enabled': str(use_native).lower()}), 
original
+
+
[email protected]('use_native', _ENGINES)
[email protected]('column', ['value', 'payload'])
[email protected]('policy', ['whole', 'mask'])
+def test_authorized_blob_row_id_updates_preserve_untouched_payload_references(
+        rest_catalog, use_native, column, policy):
+    from pypaimon.table.row.blob import BlobDescriptor
+
+    catalog, server = rest_catalog
+    table, original = _blob_table(catalog, use_native)
+    descriptors = _rows(table.copy({'blob-as-descriptor': 'true'}))
+    assert BlobDescriptor.is_blob_descriptor(descriptors[0]['payload'])
+    assert BlobDescriptor.is_blob_descriptor(descriptors[1]['payload'])
+    data = pa.table({'_ROW_ID': [0], column: pa.array(
+        [b'changed' * 15] if column == 'payload' else [111],
+        type=pa.large_binary() if column == 'payload' else pa.int32())})
+    builder = table.new_batch_write_builder()
+    before = _files(table)
+    with ExitStack() as stack:
+        if policy == 'mask':
+            stack.enter_context(_restricted(server, table, policy))
+        _native_only(stack, use_native)
+        if policy == 'mask':
+            with pytest.raises(Exception, 
match='[Pp]ermission|denied|unrestricted|restrict|[Mm]ask|query.auth'):
+                builder.new_update().update_by_arrow_with_row_id(data)
+            assert _files(table) == before
+            return
+        messages = builder.new_update().update_by_arrow_with_row_id(data)
+    builder.new_commit().commit(messages)
+    expected = [dict(row) for row in original]
+    expected[0][column] = b'changed' * 15 if column == 'payload' else 111
+    assert _rows(table) == expected
+    after = _rows(table.copy({'blob-as-descriptor': 'true'}))
+    assert after[1]['payload'] == descriptors[1]['payload']
+    if column == 'value':
+        assert [row['payload'] for row in after] == [row['payload'] for row in 
descriptors]
+    else:
+        assert after[0]['payload'] != descriptors[0]['payload']
+
+
+def _merge_source_view(catalog, view):
+    catalog.create_table('default.source', Schema.from_pyarrow_schema(_SCHEMA, 
options={
+        'query-auth.enabled': 'true', 'write.native.enabled': 'false', 
'commit.native.enabled': 'false',
+        'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
+    }), False)
+    source = catalog.get_table('default.source')
+    captured_empty = source._copy_with_snapshot(None)
+    _append(source, [dict(id=1, value=11, name='source-old')])
+    first = source.snapshot_manager().get_latest_snapshot()
+    if view == 'snapshot-id':
+        selected = source.copy({'scan.snapshot-id': str(first.id)})
+    elif view == 'captured':
+        selected = source._copy_with_snapshot(first)
+    elif view == 'captured-empty':
+        selected = captured_empty
+    else:
+        assert view == 'loaded-branch'
+        catalog.create_tag(source.identifier, 'first', first.id)
+        catalog.create_branch(source.identifier, 'dev', tag_name='first')
+        selected = catalog.get_table('default.source$branch_dev')
+    _append(source, [dict(id=4, value=40, name='source-latest')])
+    return selected
+
+
[email protected]('use_native', _ENGINES)
[email protected]('view', ['snapshot-id', 'captured', 'captured-empty', 
'loaded-branch'])
+def test_authorized_merge_keeps_selected_source_view(rest_catalog, use_native, 
view):
+    from urllib.parse import unquote
+
+    catalog, server = rest_catalog
+    table = _table(catalog, use_native)
+    source = _merge_source_view(catalog, view)
+    builder = table.new_batch_write_builder()
+    with patch.object(server, '_route_request', wraps=server._route_request) 
as requests:
+        with ExitStack() as stack:
+            _native_only(stack, use_native)
+            messages = builder.new_update().merge_into(
+                source, on=['id'], when_matched=[WhenMatched.update({'value': 
source_col('value')})],
+                when_not_matched=[WhenNotMatched('*')])
+        expected_path = '/source$branch_dev/auth' if view == 'loaded-branch' 
else '/source/auth'
+        assert any(unquote(call.args[1]).endswith(expected_path) for call in 
requests.call_args_list)
+    if view == 'captured-empty':
+        assert messages == []
+        assert _rows(table) == _ROWS
+    else:
+        assert messages
+        builder.new_commit().commit(messages)
+        assert _rows(table) == [dict(id=1, value=11, name='a')] + _ROWS[1:]
+
+
[email protected]('use_native', _ENGINES)
[email protected]('policy', ['denied', 'mask'])
+def test_authorized_merge_branch_source_checks_its_own_grant(rest_catalog, 
use_native, policy):
+    from urllib.parse import unquote
+
+    catalog, server = rest_catalog
+    table = _table(catalog, use_native)
+    source = _merge_source_view(catalog, 'loaded-branch')
+    before = _files(table)
+    assignment = Mock(side_effect=AssertionError('branch source was read 
without authorization'))
+    original = server._table_query_auth_handle
+
+    def authorize(method, data, identifier):
+        # RESTCatalogServer resolves branch metadata before passing the base
+        # identifier here. Verify the branch-bearing HTTP path separately.
+        if identifier.get_full_name() == 'default.source':
+            if policy == 'denied':
+                return server._mock_response(ErrorResponse('TABLE', 'source', 
'SELECT denied', 403), 403)
+            return 
server._mock_response(AuthTableQueryResponse(column_masking={
+                'name': json.dumps({'name': 'CONCAT', 'inputs': 
['masked']})}), 200)
+        return original(method, data, identifier)
+
+    with patch.object(server, '_table_query_auth_handle', 
side_effect=authorize), \
+            patch.object(server, '_route_request', 
wraps=server._route_request) as requests:
+        with ExitStack() as stack:
+            _native_only(stack, use_native)
+            with pytest.raises(Exception, 
match='[Pp]ermission|denied|unrestricted|restrict|[Mm]ask|query.auth'):
+                table.new_batch_write_builder().new_update().merge_into(
+                    source, on=['id'], 
when_matched=[WhenMatched.update({'value': assignment})])
+        assert any(unquote(call.args[1]).endswith('/source$branch_dev/auth')
+                   for call in requests.call_args_list)
+    assignment.assert_not_called()
+    assert _files(table) == before
diff --git a/paimon-python/pypaimon/tests/native_commit_semantics_test.py 
b/paimon-python/pypaimon/tests/native_commit_semantics_test.py
new file mode 100644
index 0000000000..8ee8e12c6c
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_commit_semantics_test.py
@@ -0,0 +1,244 @@
+# 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.
+
+"""REST commits preserve Java's empty-overwrite and changelog-only 
semantics."""
+
+from contextlib import contextmanager
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.manifest.manifest_file_manager import ManifestFileManager
+from pypaimon.manifest.manifest_list_manager import ManifestListManager
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
+from pypaimon.tests import native_plan_rest_test
+from pypaimon.write.commit_message import CommitMessage
+
+
+rest_catalog = native_plan_rest_test.rest_catalog
+pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
+_ENGINES = [False, pytest.param(True, marks=pytest.mark.skipif(
+    not native_runtime_available(), reason='pypaimon-rust not installed'))]
+_SCHEMA = pa.schema([('id', pa.int64()), ('pt', pa.string()), ('region', 
pa.string())])
+_ROWS = [dict(id=1, pt='a', region='us'), dict(id=2, pt='b', region='eu')]
+
+
+def _table(catalog, use_native, ignore_empty, primary_key=False):
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        _SCHEMA, primary_keys=['id'] if primary_key else [],
+        partition_keys=[] if primary_key else ['pt', 'region'], options={
+            'query-auth.enabled': 'true', 'bucket': '1' if primary_key else 
'-1',
+            'changelog-producer': 'input' if primary_key else 'none',
+            'write.native.enabled': 'false', 'read.native.enabled': 'false',
+            'scan.native-plan.enabled': 'false',
+            'commit.native.enabled': str(use_native).lower(),
+            'snapshot.ignore-empty-commit': str(ignore_empty).lower(),
+            'commit.min-retry-wait': '0 ms', 'commit.max-retry-wait': '0 ms',
+        }), False)
+    return catalog.get_table('default.t')
+
+
+def _prepare(builder, rows, identifier=None):
+    writer = builder.new_write()
+    try:
+        writer.write_arrow(pa.Table.from_pylist(rows, schema=_SCHEMA))
+        return writer.prepare_commit() if identifier is None else 
writer.prepare_commit(identifier)
+    finally:
+        writer.close()
+
+
+@contextmanager
+def _publication(server, commit, use_native, lose_response=False):
+    original = server._table_commit_handle
+    attempts = []
+
+    def publish(*args, **kwargs):
+        response = original(*args, **kwargs)
+        attempts.append(response[1])
+        assert response[1] == 200
+        # The snapshot is published but the client cannot decode its response.
+        return ('not-json', 200) if lose_response else response
+
+    with patch.object(server, '_table_commit_handle', side_effect=publish), \
+            patch.object(server, '_table_query_auth_handle',
+                         side_effect=AssertionError('publication must not 
request SELECT')), \
+            patch.object(commit.file_store_commit, 'abort', 
side_effect=AssertionError('implicit abort')):
+        if use_native:
+            with patch.object(commit.file_store_commit, '_try_commit',
+                              side_effect=AssertionError('native commit fell 
back')):
+                yield attempts
+        else:
+            yield attempts
+
+
[email protected]('use_native', _ENGINES)
[email protected]('ignore_empty', [False, True])
[email protected]('target', ['empty-table', 'empty-partition', 
'missing-partition', 'partial-spec'])
+def test_truncate_records_empty_overwrite_and_notifies(rest_catalog, 
use_native, ignore_empty, target):
+    catalog, server = rest_catalog
+    table = _table(catalog, use_native, ignore_empty)
+    seeded = target in ('missing-partition', 'partial-spec')
+    if seeded:
+        builder = table.copy({'commit.native.enabled': 
'false'}).new_batch_write_builder()
+        seed_commit = builder.new_commit()
+        try:
+            seed_commit.commit(_prepare(builder, _ROWS))
+        finally:
+            seed_commit.close()
+    commit = table.new_batch_write_builder().new_commit()
+    callback = Mock()
+    commit.add_commit_callback(callback)
+    try:
+        with _publication(server, commit, use_native) as attempts:
+            if target == 'empty-table':
+                commit.truncate_table()
+            else:
+                spec = {'pt': 'absent'}
+                if target != 'partial-spec':
+                    spec['region'] = 'us'
+                commit.truncate_partitions([spec])
+        assert attempts == [200]
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        assert snapshot.id == (2 if seeded else 1)
+        assert snapshot.commit_kind == 'OVERWRITE'
+        assert snapshot.commit_identifier == BATCH_COMMIT_IDENTIFIER
+        assert snapshot.total_record_count == (2 if seeded else 0)
+        assert snapshot.delta_record_count == 0
+        callback.call.assert_called_once()
+        context = callback.call.call_args[0][0]
+        assert context.snapshot == snapshot
+        assert context.identifier == BATCH_COMMIT_IDENTIFIER
+        assert context.commit_entries == []
+    finally:
+        commit.close()
+    callback.close.assert_called_once()
+
+
[email protected]('use_native', _ENGINES)
[email protected]('ignore_empty', [False, True])
[email protected]('stream', [False, True])
[email protected]('lose_response', [False, True])
+def test_changelog_only_commit_keeps_snapshot_and_recovers_once(
+        rest_catalog, use_native, ignore_empty, stream, lose_response):
+    catalog, server = rest_catalog
+    table = _table(catalog, use_native, ignore_empty, primary_key=True)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    prepared = _prepare(builder, _ROWS[:1], 7 if stream else None)
+    assert sum(len(message.changelog_files) for message in prepared) == 1
+    changes = [CommitMessage(message.partition, message.bucket, [],
+                             changelog_files=message.changelog_files) for 
message in prepared]
+    paths = [file.file_path for message in prepared for file in 
message.new_files + message.changelog_files]
+    commit = builder.new_commit()
+    callback = Mock()
+    commit.add_commit_callback(callback)
+    try:
+        with _publication(server, commit, use_native, lose_response) as 
attempts:
+            args = (changes, 7) if stream else (changes,)
+            commit.commit(*args, snapshot_properties={'source': 
'changelog-only'})
+        assert attempts == [200], 'lost response recovery must not publish a 
second snapshot'
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        assert snapshot.id == 1
+        assert snapshot.commit_kind == 'APPEND'
+        assert snapshot.commit_identifier == (7 if stream else 
BATCH_COMMIT_IDENTIFIER)
+        assert snapshot.total_record_count == snapshot.delta_record_count == 0
+        assert snapshot.changelog_record_count == 1
+        assert snapshot.changelog_manifest_list
+        assert snapshot.properties == {'source': 'changelog-only'}
+        manifests = ManifestListManager(table).read_changelog(snapshot)
+        entries = [entry for manifest in manifests for entry in 
ManifestFileManager(table).read(
+            manifest.file_name, drop_stats=False, 
file_size=manifest.file_size)]
+        assert [entry.file.file_name for entry in entries] == [
+            file.file_name for message in changes for file in 
message.changelog_files]
+        callback.call.assert_called_once()
+        context = callback.call.call_args[0][0]
+        assert context.snapshot == snapshot
+        assert context.identifier == snapshot.commit_identifier
+        assert context.commit_entries == [], 'changelogs are not data-file 
deltas'
+        assert all(table.file_io.exists(path) for path in paths)
+
+        if stream:
+            with _publication(server, commit, use_native) as empty_attempts:
+                commit.commit([], 8)
+            latest = table.snapshot_manager().get_latest_snapshot()
+            if ignore_empty:
+                assert empty_attempts == []
+                assert latest.id == 1
+                callback.call.assert_called_once()
+            else:
+                assert empty_attempts == [200]
+                assert latest.id == 2
+                assert latest.commit_identifier == 8
+                assert latest.properties is None
+                assert latest.changelog_manifest_list is None
+                assert latest.changelog_record_count is None
+                assert callback.call.call_count == 2
+        else:
+            with pytest.raises(RuntimeError, match='one-time'):
+                commit.commit([])
+            assert table.snapshot_manager().get_latest_snapshot().id == 1
+    finally:
+        commit.close()
+    callback.close.assert_called_once()
+
+
[email protected]('use_native', _ENGINES)
[email protected]('operation', ['truncate-table', 
'truncate-partitions', 'changelog-only'])
+def 
test_zero_data_snapshot_callback_error_preserves_confirmed_publication(rest_catalog,
 use_native, operation):
+    catalog, server = rest_catalog
+    table = _table(catalog, use_native, True, primary_key=operation == 
'changelog-only')
+    builder = table.new_batch_write_builder()
+    paths = []
+    changes = []
+    if operation == 'changelog-only':
+        prepared = _prepare(builder, _ROWS[:1])
+        changes = [CommitMessage(message.partition, message.bucket, [],
+                                 changelog_files=message.changelog_files) for 
message in prepared]
+        assert sum(len(message.changelog_files) for message in changes) == 1
+        paths = [file.file_path for message in prepared for file in 
message.new_files + message.changelog_files]
+    commit = builder.new_commit()
+    failure = LookupError('notification failed after publication')
+    callback = Mock()
+    callback.call.side_effect = failure
+    follower = Mock()
+    commit.add_commit_callback(callback)
+    commit.add_commit_callback(follower)
+    try:
+        with _publication(server, commit, use_native, lose_response=True) as 
attempts:
+            with pytest.raises(LookupError) as error:
+                if operation == 'truncate-table':
+                    commit.truncate_table()
+                elif operation == 'truncate-partitions':
+                    commit.truncate_partitions([{'pt': 'absent'}])
+                else:
+                    commit.commit(changes)
+        assert error.value is failure
+        assert attempts == [200]
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        assert snapshot.id == 1
+        assert snapshot.commit_kind == ('APPEND' if operation == 
'changelog-only' else 'OVERWRITE')
+        assert snapshot.total_record_count == snapshot.delta_record_count == 0
+        callback.call.assert_called_once()
+        assert callback.call.call_args[0][0].snapshot == snapshot
+        follower.call.assert_not_called()
+        assert all(table.file_io.exists(path) for path in paths)
+    finally:
+        commit.close()
+    callback.close.assert_called_once()
+    follower.close.assert_called_once()
diff --git a/paimon-python/pypaimon/tests/native_overwrite_indexes_test.py 
b/paimon-python/pypaimon/tests/native_overwrite_indexes_test.py
new file mode 100644
index 0000000000..2e228de3f3
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_overwrite_indexes_test.py
@@ -0,0 +1,357 @@
+# 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.
+
+"""Overwrite retires matching index metadata without deleting historical 
files."""
+
+import json
+from contextlib import ExitStack
+from copy import deepcopy
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.globalindex.create_global_index import GlobalIndexBuilder
+from pypaimon.globalindex.drop_global_index import GlobalIndexDropper
+from pypaimon.index.index_file_handler import IndexFileHandler
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.tests import native_plan_rest_test
+from pypaimon.tests.native_commit_semantics_test import _publication
+
+
+rest_catalog = native_plan_rest_test.rest_catalog
+pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
+_ENGINES = [False, pytest.param(True, marks=pytest.mark.skipif(
+    not native_runtime_available(), reason='pypaimon-rust not installed'))]
+_SCHEMA = pa.schema([('id', pa.int64()), ('pt', pa.string()), ('region', 
pa.string())])
+_ROWS = [dict(id=1, pt='a', region='us'), dict(id=2, pt='a', region='us'),
+         dict(id=3, pt='b', region='eu'), dict(id=4, pt='b', region='eu')]
+
+
+def _write(table, rows):
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.Table.from_pylist(rows, schema=_SCHEMA))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+
+
+def _indexed_table(catalog, index_type):
+    options = {
+        'bucket': '-1', 'dynamic-bucket.target-row-num': '100',
+        'dynamic-partition-overwrite': 'false',
+        'write.native.enabled': 'false', 'commit.native.enabled': 'false',
+        'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
+        'commit.min-retry-wait': '0 ms', 'commit.max-retry-wait': '0 ms',
+    }
+    if index_type != 'HASH':
+        options.update({'data-evolution.enabled': 'true', 
'row-tracking.enabled': 'true',
+                        'deletion-vectors.enabled': 'true' if index_type == 
'DELETION_VECTORS' else 'false'})
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        _SCHEMA, partition_keys=['pt', 'region'],
+        primary_keys=['id', 'pt', 'region'] if index_type == 'HASH' else [], 
options=options), False)
+    table = catalog.get_table('default.t')
+    _write(table, _ROWS)
+    if index_type == 'DELETION_VECTORS':
+        _delete(table, [0, 2])
+    elif index_type == 'btree':
+        assert table.create_global_index('id') == 2
+    entries = 
IndexFileHandler(table).scan(table.snapshot_manager().get_latest_snapshot())
+    assert {entry.index_file.index_type for entry in entries} == {index_type}
+    assert {entry.partition.values[0] for entry in entries} == {'a', 'b'}
+    return table
+
+
+def _delete(table, row_ids):
+    builder = table.new_batch_write_builder()
+    commit = builder.new_commit()
+    try:
+        commit.commit(builder.new_update().delete_by_row_id(row_ids))
+    finally:
+        commit.close()
+
+
+def _rows(table, snapshot=None):
+    if snapshot is not None:
+        table = table._copy_with_snapshot(snapshot)
+    builder = table.new_read_builder()
+    return 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by('id').to_pylist()
+
+
+def _index_paths(table, entries):
+    factory = table.path_factory()
+    return [entry.index_file.external_path or (
+        factory.bucket_index_path(tuple(entry.partition.values), entry.bucket, 
entry.index_file)
+        if entry.index_file.index_type in ('HASH', 'DELETION_VECTORS') else
+        
factory.global_index_path_factory().to_path(entry.index_file.file_name)) for 
entry in entries]
+
+
+def _native_only(commit, use_native):
+    return (patch.object(commit.file_store_commit, '_try_commit', 
side_effect=AssertionError('Python fallback'))
+            if use_native else ExitStack())
+
+
[email protected]('use_native', _ENGINES)
[email protected]('index_type', ['HASH', 'DELETION_VECTORS', 'btree'])
[email protected]('operation', ['truncate-table', 
'truncate-partitions', 'overwrite'])
+def test_overwrite_retires_only_matching_index_metadata(rest_catalog, 
use_native, index_type, operation):
+    catalog, _ = rest_catalog
+    table = _indexed_table(catalog, index_type)
+    before = table.snapshot_manager().get_latest_snapshot()
+    original_rows = _rows(table)
+    original_indexes = IndexFileHandler(table).scan(before)
+    paths = _index_paths(table, original_indexes)
+    table = table.copy({'commit.native.enabled': str(use_native).lower()})
+    builder = table.new_batch_write_builder()
+    if operation == 'overwrite':
+        builder.overwrite({'pt': 'a'})
+    commit = builder.new_commit()
+    writer = None
+    try:
+        with _native_only(commit, use_native):
+            if operation == 'truncate-table':
+                commit.truncate_table()
+            elif operation == 'truncate-partitions':
+                commit.truncate_partitions([{'pt': 'a'}])
+            else:
+                writer = builder.new_write()
+                writer.write_arrow(pa.Table.from_pylist([dict(id=10, pt='a', 
region='us')], schema=_SCHEMA))
+                commit.commit(writer.prepare_commit())
+        latest = table.snapshot_manager().get_latest_snapshot()
+        remaining = IndexFileHandler(table).scan(latest)
+        old_names = {entry.index_file.file_name for entry in original_indexes 
if entry.partition.values[0] == 'a'}
+        assert not old_names.intersection(entry.index_file.file_name for entry 
in remaining)
+        if operation == 'truncate-table':
+            assert latest.index_manifest is None
+            expected = []
+        else:
+            retained_names = {entry.index_file.file_name for entry in 
remaining if entry.partition.values[0] == 'b'}
+            assert retained_names == {entry.index_file.file_name for entry in 
original_indexes
+                                      if entry.partition.values[0] == 'b'}
+            expected = [row for row in original_rows if row['pt'] == 'b']
+            if operation == 'overwrite':
+                expected = [dict(id=10, pt='a', region='us')] + expected
+        assert _rows(table) == sorted(expected, key=lambda row: row['id'])
+        assert _rows(table, before) == original_rows
+        assert all(table.file_io.exists(path) for path in paths)
+        assert table.file_io.exists(table.table_path.rstrip('/') + 
'/manifest/' + before.index_manifest)
+    finally:
+        if writer is not None:
+            writer.close()
+        commit.close()
+
+
[email protected]('use_native', _ENGINES)
[email protected]('index_type', ['HASH', 'DELETION_VECTORS'])
+def 
test_truncate_retry_replans_indexes_added_by_concurrent_writer(rest_catalog, 
use_native, index_type):
+    catalog, server = rest_catalog
+    table = _indexed_table(catalog, index_type)
+    original = table.snapshot_manager().get_latest_snapshot()
+    original_rows = _rows(table)
+    builder = table.copy({'commit.native.enabled': 
str(use_native).lower()}).new_batch_write_builder()
+    commit = builder.new_commit()
+    publish = server._table_commit_handle
+    concurrent = []
+    attempts = []
+
+    def change_before_first_compare_and_swap(method, data, identifier, 
branch=None):
+        request = json.loads(data)
+        if request['snapshot']['commitUser'] == builder.commit_user:
+            attempts.append(request['snapshot']['id'])
+            if len(attempts) == 1:
+                # A direct storage writer runs inside the single-threaded mock
+                # REST server, avoiding a recursive HTTP request to that 
server.
+                storage = server._get_file_table(identifier, branch)
+                if index_type == 'HASH':
+                    _write(storage, [dict(id=5, pt='a', region='us')])
+                else:
+                    _delete(storage, [1])
+                snapshot = storage.snapshot_manager().get_latest_snapshot()
+                concurrent.extend(IndexFileHandler(storage).scan(snapshot))
+        return publish(method, data, identifier, branch)
+
+    try:
+        with patch.object(server, '_table_commit_handle', 
side_effect=change_before_first_compare_and_swap), \
+                _native_only(commit, use_native):
+            commit.truncate_partitions([{'pt': 'a'}])
+        assert attempts == [original.id + 1, original.id + 2]
+        latest = table.snapshot_manager().get_latest_snapshot()
+        remaining = IndexFileHandler(table).scan(latest)
+        assert {entry.partition.values[0] for entry in remaining} == {'b'}
+        assert {entry.index_file.file_name for entry in remaining} == {
+            entry.index_file.file_name for entry in concurrent if 
entry.partition.values[0] == 'b'}
+        assert all(table.file_io.exists(path) for path in _index_paths(table, 
concurrent))
+        assert _rows(table) == [row for row in original_rows if row['pt'] == 
'b']
+        assert _rows(table, original) == original_rows
+    finally:
+        commit.close()
+
+
[email protected]('use_native', _ENGINES)
[email protected]('index_change', ['add', 'delete'])
[email protected]('with_data', [False, True])
+def test_dynamic_overwrite_targets_only_partitions_with_data_changes(
+        rest_catalog, use_native, index_change, with_data):
+    catalog, _ = rest_catalog
+    table = _indexed_table(catalog, 'btree')
+    before = table.snapshot_manager().get_latest_snapshot()
+    before_indexes = IndexFileHandler(table).scan(before)
+    if index_change == 'delete':
+        index_messages = GlobalIndexDropper(table, 'id', partitions={'pt': 
'b'}).build()
+    else:
+        # Prepared index files are legitimate outputs of the existing builder;
+        # an otherwise empty dynamic overwrite must leave them uncommitted.
+        index_messages = GlobalIndexBuilder(
+            table, 'id', index_type='bitmap', partitions={'pt': 'b'}).build()
+    assert index_messages and all(not message.new_files for message in 
index_messages)
+    index_paths = _index_paths(
+        table, [entry for message in index_messages for entry in 
message.index_adds + message.index_deletes])
+    table = table.copy({'dynamic-partition-overwrite': 'true',
+                        'commit.native.enabled': str(use_native).lower()})
+    builder = table.new_batch_write_builder().overwrite()
+    writer, commit = builder.new_write(), builder.new_commit()
+    callback = Mock()
+    commit.add_commit_callback(callback)
+    try:
+        messages = []
+        if with_data:
+            writer.write_arrow(pa.Table.from_pylist([dict(id=10, pt='a', 
region='us')], schema=_SCHEMA))
+            messages.extend(writer.prepare_commit())
+        messages.extend(index_messages)
+        with _native_only(commit, use_native):
+            commit.commit(messages)
+        latest = table.snapshot_manager().get_latest_snapshot()
+        if with_data:
+            assert latest.id == before.id + 1
+            assert _rows(table) == [dict(id=3, pt='b', region='eu'), 
dict(id=4, pt='b', region='eu'),
+                                    dict(id=10, pt='a', region='us')]
+            callback.call.assert_called_once()
+            assert all(tuple(entry.partition.values) == ('a', 'us')
+                       for entry in 
callback.call.call_args[0][0].commit_entries)
+            remaining = IndexFileHandler(table).scan(latest)
+            if index_change == 'delete':
+                assert remaining == []
+            else:
+                assert {entry.index_file.index_type for entry in remaining} == 
{'btree', 'bitmap'}
+                assert {entry.partition.values[0] for entry in remaining} == 
{'b'}
+        else:
+            assert latest == before
+            assert IndexFileHandler(table).scan(latest) == before_indexes
+            assert _rows(table) == _ROWS
+            callback.call.assert_not_called()
+        assert all(table.file_io.exists(path) for path in index_paths)
+        assert _rows(table, before) == _ROWS
+    finally:
+        writer.close()
+        commit.close()
+
+
[email protected]('use_native', _ENGINES)
[email protected]('mismatch', ['missing', 'duplicate', 'type', 
'partition', 'bucket', 'size', 'row-count',
+                                      'external-path', 'row-range', 
'field-id', 'extra-fields', 'empty-extra-fields',
+                                      'index-meta', 'source-meta'])
+def test_stale_global_index_delete_fails_before_publication(rest_catalog, 
use_native, mismatch):
+    catalog, _ = rest_catalog
+    table = _indexed_table(catalog, 'btree')
+    changes = GlobalIndexDropper(table, 'id', partitions={'pt': 'a'}).build()
+    paths = _index_paths(table, [entry for message in changes for entry in 
message.index_deletes])
+    if mismatch == 'missing':
+        first = table.new_batch_write_builder().new_commit()
+        try:
+            first.commit(deepcopy(changes))
+        finally:
+            first.close()
+    else:
+        entry = changes[0].index_deletes[0]
+        meta = entry.index_file
+        if mismatch == 'duplicate':
+            changes[0].index_deletes.append(deepcopy(entry))
+        elif mismatch == 'type':
+            meta.index_type = 'bitmap'
+        elif mismatch == 'partition':
+            entry.partition = deepcopy(entry.partition)
+            entry.partition.values[0] = 'b'
+            changes[0].partition = tuple(entry.partition.values)
+        elif mismatch == 'bucket':
+            entry.bucket += 1
+            changes[0].bucket = entry.bucket
+        elif mismatch == 'size':
+            meta.file_size += 1
+        elif mismatch == 'row-count':
+            meta.row_count += 1
+        elif mismatch == 'external-path':
+            meta.external_path = paths[0] + '.other'
+        elif mismatch == 'row-range':
+            meta.global_index_meta.row_range_start += 1
+        elif mismatch == 'field-id':
+            meta.global_index_meta.index_field_id += 1
+        elif mismatch == 'extra-fields':
+            meta.global_index_meta.extra_field_ids = [1]
+        elif mismatch == 'empty-extra-fields':
+            meta.global_index_meta.extra_field_ids = []
+        elif mismatch == 'index-meta':
+            meta.global_index_meta.index_meta = b'different'
+        elif mismatch == 'source-meta':
+            meta.global_index_meta.source_meta = b'different'
+    before = table.snapshot_manager().get_latest_snapshot()
+    before_indexes = IndexFileHandler(table).scan(before)
+    table = table.copy({'commit.native.enabled': str(use_native).lower()})
+    commit = table.new_batch_write_builder().new_commit()
+    callback = Mock()
+    commit.add_commit_callback(callback)
+    try:
+        with _native_only(commit, use_native), pytest.raises(
+                (RuntimeError, ValueError), match='does not exist|metadata 
does not match'):
+            commit.commit(changes)
+        assert table.snapshot_manager().get_latest_snapshot() == before
+        assert IndexFileHandler(table).scan(before) == before_indexes
+        callback.call.assert_not_called()
+        assert all(table.file_io.exists(path) for path in paths)
+        assert _rows(table) == _ROWS
+    finally:
+        commit.close()
+
+
[email protected]('use_native', _ENGINES)
+def 
test_global_index_delete_recovers_lost_response_before_rechecking_current_files(rest_catalog,
 use_native):
+    catalog, server = rest_catalog
+    table = _indexed_table(catalog, 'btree')
+    before = table.snapshot_manager().get_latest_snapshot()
+    changes = GlobalIndexDropper(table, 'id', partitions={'pt': 'a'}).build()
+    paths = _index_paths(table, [entry for message in changes for entry in 
message.index_deletes])
+    commit = table.copy({'commit.native.enabled': 
str(use_native).lower()}).new_batch_write_builder().new_commit()
+    callback = Mock()
+    commit.add_commit_callback(callback)
+    try:
+        with _publication(server, commit, use_native, lose_response=True) as 
attempts:
+            commit.commit(changes)
+        assert attempts == [200]
+        latest = table.snapshot_manager().get_latest_snapshot()
+        assert latest.id == before.id + 1
+        assert {entry.partition.values[0] for entry in 
IndexFileHandler(table).scan(latest)} == {'b'}
+        callback.call.assert_called_once()
+        context = callback.call.call_args[0][0]
+        assert context.snapshot == latest
+        assert context.commit_entries == []
+        assert all(table.file_io.exists(path) for path in paths)
+        assert _rows(table, before) == _rows(table) == _ROWS
+    finally:
+        commit.close()
diff --git a/paimon-python/pypaimon/tests/native_upsert_encoded_keys_test.py 
b/paimon-python/pypaimon/tests/native_upsert_encoded_keys_test.py
new file mode 100644
index 0000000000..f6e2e9e8b8
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_upsert_encoded_keys_test.py
@@ -0,0 +1,228 @@
+# 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.
+
+"""Existing upsert keys compare logical values, independently of encoding."""
+
+from contextlib import ExitStack
+from pathlib import Path
+from urllib.parse import unquote, urlparse
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.write.native_update import _native_upsert_key_type_compatible
+from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+
+
+pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
+_ENGINES = [False, pytest.param(True, marks=pytest.mark.skipif(
+    not native_runtime_available(), reason='pypaimon-rust is not installed'))]
+_KINDS = [
+    'dense_union', 'sparse_union', 'dictionary_dense_union', 
'dictionary_sparse_union',
+    pytest.param('dictionary_string_view', marks=pytest.mark.skipif(
+        not hasattr(pa, 'string_view'), reason='PyArrow view arrays are 
unavailable')),
+    pytest.param('dictionary_binary_view', marks=pytest.mark.skipif(
+        not hasattr(pa, 'binary_view'), reason='PyArrow view arrays are 
unavailable')),
+    pytest.param('dictionary_run_end', marks=pytest.mark.skipif(
+        not hasattr(pa, 'RunEndEncodedArray'), reason='PyArrow run-end arrays 
are unavailable')),
+]
+
+
+def _files(table, pattern='*'):
+    root = Path(unquote(urlparse(table.table_path).path))
+    assert root.is_dir(), table.table_path
+    return set(root.rglob(pattern))
+
+
+def _union(dense):
+    # The first logical value overflows INT and the final child never occurs.
+    # Slicing excludes the first value; inactive sparse children still contain
+    # overflows and text which must never participate in key conversion.
+    tags = pa.array([42, 7, 42, 7, 42], type=pa.int8())
+    fields = ['small', 'large', 'unused']
+    if dense:
+        children = [pa.array([0, 1, 2], type=pa.int32()),
+                    pa.array([2 ** 63 - 1, 1, None], type=pa.int64()), 
pa.array(['unused'])]
+        return pa.UnionArray.from_dense(tags, pa.array([0, 1, 1, 2, 2], 
type=pa.int32()),
+                                        children, field_names=fields, 
type_codes=[7, 42, 99])
+    children = [pa.array([0, 1, 0, 2, 0], type=pa.int32()),
+                pa.array([2 ** 63 - 1, 2 ** 63 - 1, 1, 2 ** 63 - 1, None], 
type=pa.int64()),
+                pa.array(['unused'] * 5)]
+    return pa.UnionArray.from_sparse(tags, children, field_names=fields, 
type_codes=[7, 42, 99])
+
+
+def _keys(kind):
+    if 'union' in kind:
+        values = _union('sparse' not in kind).slice(1, 4)
+        if kind.startswith('dictionary'):
+            values = pa.DictionaryArray.from_arrays(pa.array([0, None, 1, 2], 
type=pa.int8()), values)
+        else:
+            values = values.take(pa.array([0, 3, 1, 2], type=pa.int32()))
+        return pa.int32(), values, [1, 2]
+    if kind == 'dictionary_run_end':
+        values = pa.RunEndEncodedArray.from_arrays(
+            pa.array([2, 4, 5, 6], type=pa.int16()), pa.array([999, 1, 2, 
None], type=pa.int32()))
+        values = values.slice(2, 4)
+        values = pa.DictionaryArray.from_arrays(pa.array([0, None, 1, 2], 
type=pa.int8()), values)
+        return pa.int32(), values, [1, 2]
+    binary = kind == 'dictionary_binary_view'
+    first, second = (b'\xff\0', b'longer-than-inline-view') if binary else 
('a', 'longer-than-inline-view')
+    view = pa.binary_view() if binary else pa.string_view()
+    values = pa.array([first, second, None, first], type=view)
+    values = pa.DictionaryArray.from_arrays(pa.array([0, None, 3, 1], 
type=pa.int8()), values)
+    return pa.binary() if binary else pa.string(), values, [first, second]
+
+
+def _table(catalog, key_type, keys, native, guarded):
+    schema = pa.schema([('key', key_type), ('p', pa.string()), ('value', 
pa.int32()), ('keep', pa.string())])
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
partition_keys=['p'], options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'query-auth.enabled': str(guarded).lower(), 'write.native.enabled': 
'false',
+        'commit.native.enabled': 'false', 'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false',
+    }), False)
+    table = catalog.get_table('default.t')
+    rows = [dict(key=keys[0], p='a', value=10, keep='first'),
+            dict(key=keys[0], p='a', value=11, keep='duplicate'),
+            dict(key=keys[1], p='a', value=20, keep='second'),
+            dict(key=None, p='a', value=30, keep='null'),
+            dict(key=keys[0], p='b', value=40, keep='other-partition')]
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return table.copy({'write.native.enabled': str(native).lower()})
+
+
+def _native_only(stack, native):
+    if native:
+        stack.enter_context(patch.object(TableUpsertByKey, '_upsert_partition',
+                                         side_effect=AssertionError('Python 
Arrow upsert fallback')))
+        stack.enter_context(patch.object(TableUpsertByKey, 
'_upsert_row_partition',
+                                         side_effect=AssertionError('Python 
Row upsert fallback')))
+
+
+def _read(table):
+    read = table.new_read_builder()
+    return 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('keep').to_pylist()
+
+
[email protected]('native', _ENGINES)
[email protected]('guarded', [False, True])
[email protected]('stream', [False, True])
[email protected]('kind', _KINDS)
+def test_encoded_upsert_keys_preserve_last_wins_nulls_and_partitions(
+        native_rest_catalog, native, guarded, stream, kind):
+    key_type, keys, seed = _keys(kind)
+    table = _table(native_rest_catalog, key_type, seed, native, guarded)
+    values = pa.array([90, 91, 92, 93], type=pa.int32())
+    # Duplicate source keys cross Arrow batch boundaries, while target keys
+    # fan out to duplicate existing rows within one partition only.
+    source = pa.table({'value': values, 'p': ['a'] * 4, 'key': keys})
+    source = pa.concat_tables([source.slice(0, 2), source.slice(2)])
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    with ExitStack() as stack:
+        _native_only(stack, native)
+        update = builder.new_update().with_update_type(['value'])
+        messages = (update.upsert_by_arrow_with_key(source, ['key'], 17) if 
stream
+                    else update.upsert_by_arrow_with_key(source, ['key']))
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 17) if stream else commit.commit(messages)
+    finally:
+        commit.close()
+    assert _read(table) == [
+        dict(key=seed[0], p='a', value=92, keep='duplicate'),
+        dict(key=seed[0], p='a', value=92, keep='first'),
+        dict(key=None, p='a', value=91, keep='null'),
+        dict(key=seed[0], p='b', value=40, keep='other-partition'),
+        dict(key=seed[1], p='a', value=93, keep='second'),
+    ]
+
+
[email protected]('kind', _KINDS)
+def test_native_key_gate_qualifies_existing_encoded_upsert_layouts(kind):
+    key_type, keys, _ = _keys(kind)
+    assert _native_upsert_key_type_compatible(keys.type, key_type)
+    if kind in ('dictionary_string_view', 'dictionary_binary_view', 
'dictionary_run_end'):
+        assert not _native_upsert_key_type_compatible(keys.dictionary.type, 
key_type), (
+            'Bare views/run-end arrays have no PyArrow take kernel and remain 
outside this bridge')
+
+
[email protected]('native', _ENGINES)
[email protected]('invalid', [2 ** 63 - 1, 1.5, '1'])
+def test_active_union_key_cast_failure_never_matches_or_stages(
+        native_rest_catalog, native, invalid):
+    table = _table(native_rest_catalog, pa.int32(), [1, 2], native, False)
+    child = pa.array([invalid])
+    keys = pa.UnionArray.from_dense(pa.array([7], type=pa.int8()), 
pa.array([0], type=pa.int32()),
+                                    [child, pa.array([1], type=pa.int32())], 
type_codes=[7, 42])
+    source = pa.table({'key': keys, 'p': ['a'], 'value': pa.array([99], 
type=pa.int32())})
+    before = _files(table, '*.parquet')
+    snapshot = table.snapshot_manager().get_latest_snapshot().id
+    expected = _read(table)
+    with ExitStack() as stack:
+        _native_only(stack, native)
+        with pytest.raises((ValueError, RuntimeError, pa.ArrowInvalid)):
+            
table.new_batch_write_builder().new_update().with_update_type(['value']).upsert_by_arrow_with_key(
+                source, ['key'])
+    assert table.snapshot_manager().get_latest_snapshot().id == snapshot
+    assert _files(table, '*.parquet') == before
+    assert _read(table) == expected
+
+
[email protected]('native', _ENGINES)
[email protected]('dense', [False, True])
[email protected]('dictionary', [False, True])
[email protected]('child_type', [pa.string(), pa.struct([('unused', 
pa.string())])])
+def test_typed_null_union_children_match_existing_null_keys(
+        native_rest_catalog, native, dense, dictionary, child_type):
+    table = _table(native_rest_catalog, pa.int32(), [1, 2], native, True)
+    tags = pa.array([7, 42], type=pa.int8())
+    if dense:
+        keys = pa.UnionArray.from_dense(
+            tags, pa.array([0, 0], type=pa.int32()),
+            [pa.array([1], type=pa.int32()), pa.nulls(1, type=child_type)], 
type_codes=[7, 42])
+    else:
+        inactive = 'not an integer' if pa.types.is_string(child_type) else 
{'unused': 'not an integer'}
+        keys = pa.UnionArray.from_sparse(
+            tags, [pa.array([1, 0], type=pa.int32()), pa.array([inactive, 
None], type=child_type)],
+            type_codes=[7, 42])
+    if dictionary:
+        keys = pa.DictionaryArray.from_arrays(pa.array([0, 1], 
type=pa.int8()), keys)
+    source = pa.table({'key': keys, 'p': ['a', 'a'], 'value': pa.array([90, 
91], type=pa.int32())})
+    source = pa.concat_tables([source.slice(0, 1), source.slice(1)])
+    builder = table.new_batch_write_builder()
+    with ExitStack() as stack:
+        _native_only(stack, native)
+        messages = 
builder.new_update().with_update_type(['value']).upsert_by_arrow_with_key(source,
 ['key'])
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages)
+    finally:
+        commit.close()
+    assert _read(table) == [
+        dict(key=1, p='a', value=90, keep='duplicate'), dict(key=1, p='a', 
value=90, keep='first'),
+        dict(key=None, p='a', value=91, keep='null'), dict(key=1, p='b', 
value=40, keep='other-partition'),
+        dict(key=2, p='a', value=20, keep='second'),
+    ]
diff --git a/paimon-python/pypaimon/tests/native_write_routing_test.py 
b/paimon-python/pypaimon/tests/native_write_routing_test.py
index b2871d506f..e8c5534079 100644
--- a/paimon-python/pypaimon/tests/native_write_routing_test.py
+++ b/paimon-python/pypaimon/tests/native_write_routing_test.py
@@ -159,6 +159,21 @@ def 
test_pk_complete_write_type_keeps_table_order_and_native_writer(tmp_path, na
     assert writer._python_writer is None
 
 
[email protected]('full_input', [False, True])
+def test_append_write_type_preserves_input_routing_columns_for_core(tmp_path, 
full_input):
+    underlying = Mock()
+    writer = _writer(_table(tmp_path), underlying).with_write_type(['value'])
+    data = pa.Table.from_pylist([{'id': 1, 'pt': 'a', 'value': 'x'}], 
schema=SCHEMA)
+    if not full_input:
+        data = data.select(['value'])
+    writer.write_arrow(data)
+    actual, bucket = underlying.write_arrow.call_args[0]
+    assert actual.schema.names == data.schema.names
+    assert actual.to_pylist() == data.to_pylist()
+    assert bucket is None
+    assert writer._python_writer is None
+
+
 def 
test_standard_rest_query_auth_write_table_keeps_native_publication(rest_catalog):
     catalog, _ = rest_catalog
     table = _table(None, catalog=catalog, guarded=True)
@@ -178,15 +193,15 @@ def 
test_standard_rest_query_auth_write_table_keeps_native_publication(rest_cata
     assert schema['options']['query-auth.enabled'] == 'true'
 
 
-def test_read_based_updates_keep_existing_guarded_selection(rest_catalog):
+def 
test_read_based_updates_delegate_standard_rest_authorization_to_core(rest_catalog):
     from pypaimon.write.native_update import _native_row_id_table
 
     catalog, _ = rest_catalog
     table = _table(None, 'data-evolution', catalog, use_native=True, 
guarded=True)
     with patch('pypaimon.write.native_update.native_runtime_available', 
return_value=True), \
             patch('pypaimon.write.native_update.create_native_write_table') as 
create:
-        assert _native_row_id_table(table) is None
-    create.assert_not_called()
+        assert _native_row_id_table(table) is create.return_value
+    create.assert_called_once_with(table)
 
 
 def _rows(table):
diff --git a/paimon-python/pypaimon/tests/native_write_type_test.py 
b/paimon-python/pypaimon/tests/native_write_type_test.py
new file mode 100644
index 0000000000..561a153dcc
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_write_type_test.py
@@ -0,0 +1,254 @@
+# 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.
+
+"""Selected append input columns and upstream buckets retain Java semantics."""
+
+from contextlib import ExitStack
+from pathlib import Path
+from urllib.parse import unquote, urlparse
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.tests import native_plan_rest_test
+from pypaimon.write.native_write import NativeTableWrite
+
+
+rest_catalog = native_plan_rest_test.rest_catalog
+pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
+native = pytest.mark.skipif(not native_runtime_available(), 
reason='pypaimon-rust not installed')
+_ENGINES = [False, pytest.param(True, marks=native)]
+_SCHEMA = pa.schema([('id', pa.int64()), ('pt', pa.int64()), ('value', 
pa.int64())])
+
+
+def _files(table, pattern='*'):
+    root = Path(unquote(urlparse(table.table_path).path))
+    assert root.is_dir(), table.table_path
+    return set(root.rglob(pattern))
+
+
+def _table(catalog, use_native, partitioned=False, bucketed=False, buckets=4, 
schema=_SCHEMA):
+    options = {'write.native.enabled': str(use_native).lower(), 
'commit.native.enabled': 'false',
+               'read.native.enabled': 'false', 'scan.native-plan.enabled': 
'false'}
+    if bucketed:
+        options.update({'bucket': str(buckets), 'bucket-key': 'id'})
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        schema, partition_keys=['pt'] if partitioned else [], 
options=options), False)
+    return catalog.get_table('default.t')
+
+
+def _rows(table):
+    builder = table.new_read_builder()
+    return 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by('value').to_pylist()
+
+
+def _native_only(stack, writer, use_native):
+    assert isinstance(writer, NativeTableWrite) == use_native
+    if use_native:
+        stack.enter_context(patch.object(writer, '_switch_to_python', 
side_effect=AssertionError('Python fallback')))
+
+
+def _finish(builder, writer):
+    messages = writer.prepare_commit()
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages)
+    finally:
+        commit.close()
+    return messages
+
+
[email protected]('use_native', _ENGINES)
[email protected]('partitioned', [False, True])
[email protected]('full_input', [False, True])
+def test_upstream_bucket_subset_omits_hash_columns_without_leaking_full_input(
+        rest_catalog, use_native, partitioned, full_input):
+    catalog, _ = rest_catalog
+    table = _table(catalog, use_native, partitioned, bucketed=True)
+    columns = ['value', 'pt'] if partitioned else ['value']
+    data = pa.Table.from_pylist([dict(id=1, pt=7, value=42)], schema=_SCHEMA)
+    data = data if full_input else data.select(columns).to_batches()[0]
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        with ExitStack() as stack:
+            _native_only(stack, writer, use_native)
+            writer.with_write_type(columns)
+            with pytest.raises(ValueError, match='missing.*not in table 
schema|Column.*missing'):
+                writer.with_write_type(['missing'])
+            before = _files(table)
+            # Java does not route zero rows, even when the selected input
+            # could only be written with an upstream bucket for nonempty data.
+            writer.write_arrow(data.slice(0, 0))
+            assert _files(table) == before
+            if not full_input:
+                with pytest.raises(ValueError, match="routing column 'id'"):
+                    writer.write_arrow(data)
+                assert _files(table) == before
+            writer.write_arrow(data, bucket=2)
+            messages = _finish(builder, writer)
+        assert [message.bucket for message in messages] == [2]
+        assert all(file.write_cols == columns for message in messages for file 
in message.new_files)
+        assert _rows(table) == [dict(id=None, pt=7 if partitioned else None, 
value=42)]
+    finally:
+        writer.close()
+
+
[email protected]('use_native', _ENGINES)
+def 
test_partial_upstream_groups_reject_invalid_buckets_and_mixed_partitions(rest_catalog,
 use_native):
+    catalog, _ = rest_catalog
+    table = _table(catalog, use_native, partitioned=True, bucketed=True)
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    data = pa.table({'value': [42, 43], 'pt': [7, 8]})
+    try:
+        with ExitStack() as stack:
+            _native_only(stack, writer, use_native)
+            writer.with_write_type(['value', 'pt'])
+            before = _files(table)
+            for bucket in (-1, 4):
+                with pytest.raises(ValueError, match='Bucket id'):
+                    writer.write_arrow(data, bucket=bucket)
+            with pytest.raises(ValueError, match='multiple partitions'):
+                writer.write_arrow(data, bucket=2)
+            assert _files(table) == before
+            writer.write_arrow(data.slice(0, 1), bucket=2)
+            _finish(builder, writer)
+        assert _rows(table) == [dict(id=None, pt=7, value=42)]
+    finally:
+        writer.close()
+
+
[email protected]('use_native', _ENGINES)
[email protected]('partitioned', [False, True])
[email protected]('full_input', [False, True])
+def 
test_complete_reordered_append_type_keeps_field_identity_and_copies_column_names(
+        rest_catalog, use_native, partitioned, full_input):
+    catalog, _ = rest_catalog
+    table = _table(catalog, use_native, partitioned)
+    columns = ['value', 'pt', 'id']
+    expected_columns = list(columns)
+    data = pa.Table.from_pylist([dict(id=1, pt=7, value=42)], schema=_SCHEMA)
+    data = data if full_input else data.select(columns)
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        with ExitStack() as stack:
+            _native_only(stack, writer, use_native)
+            writer.with_write_type(columns)
+            columns.reverse()
+            writer.write_arrow(data)
+            messages = _finish(builder, writer)
+        assert all(file.write_cols == expected_columns for message in messages 
for file in message.new_files)
+        assert _rows(table) == [dict(id=1, pt=7, value=42)]
+    finally:
+        writer.close()
+
+
[email protected]('use_native', _ENGINES)
[email protected]('buckets', [1, 4])
[email protected]('columns', [('value', 'pt'), ('value',)])
+def test_full_input_routes_before_projecting_unwritten_routing_fields(
+        rest_catalog, use_native, buckets, columns):
+    catalog, _ = rest_catalog
+    table = _table(catalog, use_native, partitioned=True, bucketed=True, 
buckets=buckets)
+    data = pa.Table.from_pylist([
+        dict(id=1, pt=7, value=42), dict(id=None, pt=None, value=43),
+    ], schema=_SCHEMA)
+    partitions, bucket_ids = 
table.create_row_key_extractor().extract_partition_bucket_batch(data.to_batches()[0])
+    expected_buckets = dict(zip(partitions, bucket_ids))
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        with ExitStack() as stack:
+            _native_only(stack, writer, use_native)
+            writer.with_write_type(list(columns))
+            before = _files(table)
+            writer.write_arrow(data.slice(0, 0))
+            assert _files(table) == before
+            writer.write_arrow(data)
+            messages = _finish(builder, writer)
+        assert {tuple(message.partition): message.bucket for message in 
messages} == expected_buckets
+        assert all(file.write_cols == list(columns) for message in messages 
for file in message.new_files)
+        assert _rows(table) == [dict(id=None, pt=7, value=42), dict(id=None, 
pt=None, value=43)]
+    finally:
+        writer.close()
+
+
[email protected]('use_native', _ENGINES)
[email protected]('partial_row', [False, True])
[email protected]('unwritten_blob', [False, True])
+def test_rows_route_with_unwritten_columns_without_requiring_other_fields(
+        rest_catalog, use_native, partial_row, unwritten_blob):
+    from pypaimon.table.row.blob import Blob
+
+    catalog, _ = rest_catalog
+    schema = _SCHEMA.append(pa.field('unwritten', pa.large_binary() if 
unwritten_blob else pa.int64()))
+    table = _table(catalog, use_native, partitioned=True, bucketed=True, 
schema=schema)
+    fields = {field.name: field for field in table.fields}
+    names = ['value', 'pt', 'id'] if partial_row else list(schema.names)
+    blob = Mock(spec=Blob)
+    blob.new_input_stream.side_effect = AssertionError('unwritten Blob was 
opened')
+    values = dict(id=1, pt=7, value=42, unwritten=blob if unwritten_blob else 
999)
+    row = GenericRow([values[name] for name in names], [fields[name] for name 
in names])
+    full_input = pa.Table.from_pylist([dict(values, unwritten=None)], 
schema=schema).to_batches()[0]
+    _, buckets = 
table.create_row_key_extractor().extract_partition_bucket_batch(full_input)
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        with ExitStack() as stack:
+            _native_only(stack, writer, use_native)
+            writer.with_write_type(['value'])
+            writer.write_row(row)
+            messages = _finish(builder, writer)
+        assert [(tuple(message.partition), message.bucket) for message in 
messages] == [((7,), buckets[0])]
+        assert all(file.write_cols == ['value'] for message in messages for 
file in message.new_files)
+        assert _rows(table) == [dict(id=None, pt=7, value=42, unwritten=None)]
+        blob.new_input_stream.assert_not_called()
+        blob.to_descriptor.assert_not_called()
+    finally:
+        writer.close()
+
+
[email protected]('use_native', _ENGINES)
+def 
test_upstream_bucket_partial_overwrite_preserves_other_partitions(rest_catalog, 
use_native):
+    catalog, _ = rest_catalog
+    table = _table(catalog, use_native, partitioned=True, bucketed=True)
+    seed = table.new_batch_write_builder()
+    writer = seed.new_write()
+    try:
+        for row in [dict(id=1, pt=7, value=10), dict(id=2, pt=8, value=20)]:
+            writer.write_arrow(pa.Table.from_pylist([row], schema=_SCHEMA), 
bucket=0)
+        _finish(seed, writer)
+    finally:
+        writer.close()
+    builder = table.new_batch_write_builder().overwrite({'pt': 7})
+    writer = builder.new_write()
+    try:
+        with ExitStack() as stack:
+            _native_only(stack, writer, use_native)
+            writer.with_write_type(['value', 'pt'])
+            writer.write_arrow(pa.table({'value': [42], 'pt': [7]}), bucket=2)
+            messages = _finish(builder, writer)
+        assert [message.bucket for message in messages] == [2]
+        assert _rows(table) == [dict(id=2, pt=8, value=20), dict(id=None, 
pt=7, value=42)]
+    finally:
+        writer.close()
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py 
b/paimon-python/pypaimon/tests/write/table_write_test.py
index 072d050489..47ddaef1f5 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -233,7 +233,7 @@ class TableWriteTest(unittest.TestCase):
         table_write.table_pyarrow_schema = schema
         table_write._validate_pyarrow_schema = Mock()
         table_write.row_key_extractor = Mock()
-        table_write.file_store_write = Mock()
+        table_write.file_store_write = Mock(write_cols=None)
         
table_write.row_key_extractor.extract_partition_bucket_groups.side_effect = (
             lambda data: TableWriteTest._groups_from_rows(
                 partitions, buckets, data.num_rows))
@@ -308,7 +308,7 @@ class TableWriteTest(unittest.TestCase):
         table_write.table = Mock(is_primary_key_table=False)
         table_write.table_pyarrow_schema = data.schema
         table_write._validate_pyarrow_schema = Mock()
-        table_write.file_store_write = Mock()
+        table_write.file_store_write = Mock(write_cols=None)
         table_write.row_key_extractor = Mock()
         
table_write.row_key_extractor.extract_partition_bucket_groups.return_value = [
             (('p1',), 0, pa.array([0, 2, 3], type=pa.int64())),
@@ -334,7 +334,7 @@ class TableWriteTest(unittest.TestCase):
         table_write.table = Mock(is_primary_key_table=False)
         table_write.table_pyarrow_schema = data.schema
         table_write._validate_pyarrow_schema = Mock()
-        table_write.file_store_write = Mock()
+        table_write.file_store_write = Mock(write_cols=None)
         table_write.row_key_extractor = Mock()
         
table_write.row_key_extractor.extract_partition_bucket_groups.return_value = [
             (('p0',), 0, pa.array([0], type=pa.int64())),
diff --git a/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py 
b/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
index 31e969ab7b..acd88833f5 100644
--- a/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
+++ b/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
@@ -15,8 +15,11 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from dataclasses import dataclass
 from typing import List, Optional
 
+from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+from pypaimon.manifest.index_manifest_file import IndexManifestFile
 from pypaimon.manifest.manifest_file_manager import ManifestFileManager
 from pypaimon.manifest.schema.file_entry import FileEntry
 from pypaimon.manifest.schema.manifest_entry import ManifestEntry
@@ -25,10 +28,19 @@ from pypaimon.snapshot.snapshot import Snapshot
 from pypaimon.table.row.generic_row import GenericRow
 
 
+@dataclass
+class OverwriteChanges:
+    table_files: List[ManifestEntry]
+    index_adds: List[IndexManifestEntry]
+    index_deletes: List[IndexManifestEntry]
+
+
 class OverwriteChangesProvider:
-    """Builds the commit entries (DELETE existing + ADD new) for an OVERWRITE,
-    caching the existing files of the target partitions across commit retries
-    to avoid repeated full scans.
+    """Return file and index changes (DELETE existing + ADD new) for OVERWRITE.
+
+    Existing data files of the target partitions are cached across commit
+    retries to avoid repeated full scans. Index entries come from the latest
+    snapshot on every attempt, including changes in partitions without data.
 
     On retry, if the latest snapshot advanced, the cached state is updated by
     applying the target-partition delta manifests. Missing snapshots or
@@ -51,10 +63,10 @@ class OverwriteChangesProvider:
         self.delta_probe_count = 0
         self.delta_apply_count = 0
 
-    def provide(self, latest_snapshot: Optional[Snapshot]) -> 
List[ManifestEntry]:
+    def provide(self, latest_snapshot: Optional[Snapshot]) -> OverwriteChanges:
         if latest_snapshot is None:
             # Empty table: nothing existing to delete, just add the new files.
-            return self._build_result([])
+            return self._build_result([], None)
 
         if self._cached_snapshot is None:
             self._cached_entries = 
self._full_scan_manifest_entries(latest_snapshot)
@@ -69,7 +81,7 @@ class OverwriteChangesProvider:
             self._cached_snapshot = latest_snapshot
         # cached_snapshot.id == latest_snapshot.id -> reuse cache as-is
 
-        return self._build_result(self._cached_entries)
+        return self._build_result(self._cached_entries, latest_snapshot)
 
     def _full_scan_manifest_entries(self, latest_snapshot: Snapshot) -> 
List[ManifestEntry]:
         self.full_scan_count += 1
@@ -119,8 +131,11 @@ class OverwriteChangesProvider:
                 entries.append(entry)
         return entries
 
-    def _build_result(self, existing_entries: List[ManifestEntry]) -> 
List[ManifestEntry]:
+    def _build_result(self, existing_entries: List[ManifestEntry],
+                      latest_snapshot: Optional[Snapshot]) -> OverwriteChanges:
         entries = []
+        index_adds = []
+        index_deletes = []
         # Existing files of the target partitions become DELETE entries. Build
         # fresh entries so the cached (kind=0) entries are never mutated.
         for entry in existing_entries:
@@ -131,8 +146,19 @@ class OverwriteChangesProvider:
                 total_buckets=entry.total_buckets,
                 file=entry.file,
             ))
+        # Resolve every index type from this attempt's snapshot. A concurrent
+        # writer can replace or add indexes while the data-entry cache remains
+        # reusable; deleting only the first attempt's HASH files would miss it.
+        if latest_snapshot is not None and latest_snapshot.index_manifest:
+            for entry in 
IndexManifestFile(self.table).read(latest_snapshot.index_manifest):
+                if self.partition_filter is None or 
self.partition_filter.test(entry.partition):
+                    index_deletes.append(IndexManifestEntry(
+                        kind=1, partition=entry.partition, bucket=entry.bucket,
+                        index_file=entry.index_file))
         # New files being written by this overwrite.
         for msg in self.commit_messages:
+            index_adds.extend(msg.index_adds)
+            index_deletes.extend(msg.index_deletes)
             partition = GenericRow(list(msg.partition), 
self.table.partition_keys_fields)
             total_buckets = (
                 msg.total_buckets
@@ -147,4 +173,4 @@ class OverwriteChangesProvider:
                     total_buckets=total_buckets,
                     file=file,
                 ))
-        return entries
+        return OverwriteChanges(entries, index_adds, index_deletes)
diff --git a/paimon-python/pypaimon/write/file_store_commit.py 
b/paimon-python/pypaimon/write/file_store_commit.py
index d5390088e3..8ba69b936c 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -46,7 +46,7 @@ from pypaimon.write.commit.row_id_conflict_rewriter import (
     RowIdConflictRewriter,
     RowIdRewriteResult,
 )
-from pypaimon.write.commit.overwrite_changes_provider import 
OverwriteChangesProvider
+from pypaimon.write.commit.overwrite_changes_provider import OverwriteChanges, 
OverwriteChangesProvider
 from pypaimon.table.special_fields import SpecialFields
 from pypaimon.write.commit_callback import CommitCallback, 
CommitCallbackContext
 from pypaimon.write.commit_message import CommitMessage
@@ -298,24 +298,18 @@ class FileStoreCommit:
 
         # Partition filter is built from dynamic or static partition according 
to options.
         if len(self.table.partition_keys) > 0 and 
self.table.options.dynamic_partition_overwrite():
-            if not commit_messages:
-                # In dynamic mode, if there are no changes to commit, no data 
will be deleted
+            data_messages = [message for message in commit_messages
+                             if message.new_files or message.deleted_files]
+            if not data_messages:
+                # Java derives dynamic overwrite targets from table-file
+                # changes. Index/changelog-only messages do not replace data.
                 skip_overwrite = True
             else:
-                partition_filter = 
self._create_dynamic_partition_filter(commit_messages)
+                partition_filter = 
self._create_dynamic_partition_filter(data_messages)
         else:
             partition_filter = 
self._create_static_partition_filter(overwrite_partition, commit_messages)
 
-        index_adds = [
-            entry for message in commit_messages for entry in 
message.index_adds
-        ]
-        index_deletes = [
-            entry for message in commit_messages for entry in 
message.index_deletes
-        ]
         if not skip_overwrite:
-            index_deletes = self._overwrite_hash_index_deletes(
-                partition_filter, index_deletes
-            )
             provider = self._overwrite_changes_provider(partition_filter, 
commit_messages)
             self._try_commit(
                 commit_kind="OVERWRITE",
@@ -324,40 +318,11 @@ class FileStoreCommit:
                 changelog_entries=[],
                 detect_conflicts=True,
                 allow_rollback=False,
-                index_deletes=index_deletes,
-                index_adds=index_adds,
                 snapshot_properties=snapshot_properties,
                 # Java records static/table overwrite even when no files match.
                 allow_empty_commit=True,
             )
 
-    def _overwrite_hash_index_deletes(self, partition_filter, deletes):
-        """Delete HASH indexes for every partition replaced by overwrite."""
-        from pypaimon.index.dynamic_bucket import HASH_INDEX
-        from pypaimon.index.index_file_handler import IndexFileHandler
-        from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
-        from pypaimon.table.bucket_mode import BucketMode
-
-        if self.table.bucket_mode() != BucketMode.HASH_DYNAMIC:
-            return deletes
-
-        by_file_name = {entry.index_file.file_name: entry for entry in deletes}
-        snapshot = self.snapshot_manager.get_latest_snapshot()
-        for entry in IndexFileHandler(self.table).scan(snapshot):
-            if entry.index_file.index_type != HASH_INDEX:
-                continue
-            if partition_filter is not None and not partition_filter.test(
-                entry.partition
-            ):
-                continue
-            by_file_name[entry.index_file.file_name] = IndexManifestEntry(
-                kind=1,
-                partition=entry.partition,
-                bucket=entry.bucket,
-                index_file=entry.index_file,
-            )
-        return list(by_file_name.values())
-
     def drop_partitions(self, partitions: List[Dict[str, str]], 
commit_identifier: int) -> None:
         if not partitions:
             raise ValueError("Partitions list cannot be empty.")
@@ -404,6 +369,8 @@ class FileStoreCommit:
             commit_entries_plan=provider.provide,
             detect_conflicts=True,
             allow_rollback=False,
+            # Java records a partition overwrite even when no files match.
+            allow_empty_commit=True,
         )
 
     def truncate_table(self, commit_identifier: int) -> None:
@@ -415,6 +382,7 @@ class FileStoreCommit:
             commit_entries_plan=provider.provide,
             detect_conflicts=True,
             allow_rollback=False,
+            allow_empty_commit=True,
         )
 
     def _try_commit(self, commit_kind, commit_identifier, commit_entries_plan,
@@ -438,15 +406,23 @@ class FileStoreCommit:
                     commit_kind,
                     notify_callbacks=True):
                 break
-            commit_entries = (
+            changes = (
                 rewritten_commit_entries
                 if rewritten_commit_entries is not None
                 else commit_entries_plan(latest_snapshot)
             )
+            if isinstance(changes, OverwriteChanges):
+                commit_entries = changes.table_files
+                attempt_index_adds = changes.index_adds
+                attempt_index_deletes = changes.index_deletes
+            else:
+                commit_entries = changes
+                attempt_index_adds = index_adds
+                attempt_index_deletes = index_deletes
 
             # Callers opt in when the operation records an empty snapshot.
-            if (not allow_empty_commit and not commit_entries
-                    and not index_deletes and not index_adds):
+            if (not allow_empty_commit and not commit_entries and not 
changelog_entries
+                    and not attempt_index_deletes and not attempt_index_adds):
                 break
 
             result = self._try_commit_once(
@@ -458,8 +434,8 @@ class FileStoreCommit:
                 latest_snapshot=latest_snapshot,
                 detect_conflicts=detect_conflicts,
                 allow_rollback=allow_rollback,
-                index_deletes=index_deletes,
-                index_adds=index_adds,
+                index_deletes=attempt_index_deletes,
+                index_adds=attempt_index_adds,
                 commit_result_may_be_uncertain=commit_result_may_be_uncertain,
                 snapshot_properties=snapshot_properties,
             )
diff --git a/paimon-python/pypaimon/write/native_merge_into.py 
b/paimon-python/pypaimon/write/native_merge_into.py
index 3338ccefd3..717950763f 100644
--- a/paimon-python/pypaimon/write/native_merge_into.py
+++ b/paimon-python/pypaimon/write/native_merge_into.py
@@ -82,11 +82,8 @@ def create_native_merge_into(table, source, on, matched, 
not_matched, commit_use
             return None
         if source.options.query_auth_enabled:
             from pypaimon.read.native_plan import 
_native_query_auth_supported, _native_table
-            from pypaimon.snapshot.time_travel_util import SCAN_KEYS
             from pypaimon.write.native_commit import _rest_catalog_supported
-            if (not _rest_catalog_supported(source) or not 
_native_query_auth_supported(source)
-                    or source.current_branch() != 'main' or '_read_snapshot' 
in vars(source)
-                    or any(source.table_schema.options.get(key) is not None 
for key in SCAN_KEYS)):
+            if not _rest_catalog_supported(source) or not 
_native_query_auth_supported(source):
                 return None
             # A source is a read, not a write. Core obtains a fresh REST grant
             # and requires it to be unrestricted, as Python plan_all_files 
does.
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index 610eaedd54..186f5f8f53 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -21,7 +21,7 @@ import json
 
 import pyarrow as pa
 
-from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.read.native_plan import _native_query_auth_supported, 
native_runtime_available
 from pypaimon.schema.arrow_schema import arrow_schemas_compatible
 from pypaimon.schema.data_types import MapType, PyarrowFieldParser, RowType, 
is_blob_file_field
 from pypaimon.common.options.core_options import ChangelogProducer, CoreOptions
@@ -45,13 +45,11 @@ def _native_update_table(table):
     """Resolve common native update prerequisites before preparing changes."""
     if (type(table) is not FileStoreTable
             or not table.options.native_write_enabled()
-            # These operations read target rows before staging changes. Pure
-            # write/commit support must not select an unsupported guarded read.
-            or table.options.query_auth_enabled
             or not _native_update_formats_supported(table)
             or table.options.changelog_producer() != ChangelogProducer.NONE
             or any(table.options.options.contains_key(key) for key in 
SCAN_KEYS)
-            or not native_runtime_available()):
+            or not native_runtime_available()
+            or not _native_query_auth_supported(table)):
         return None
     schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
     if not _native_partition_types_supported(
@@ -167,7 +165,22 @@ def _dictionary_value_type(data_type):
 
 def _native_upsert_key_type_compatible(source, target):
     """Qualify layouts; exact values and conversion bounds belong to core."""
+    dictionary = pa.types.is_dictionary(source)
     source = _dictionary_value_type(source)
+    if dictionary:
+        # Python's take kernel can select dictionary indices even when it
+        # cannot take the underlying view/run-end values directly. These
+        # layouts are therefore valid existing matched-upsert inputs.
+        while getattr(pa.types, 'is_run_end_encoded', lambda _: False)(source):
+            source = _dictionary_value_type(source.value_type)
+        if getattr(pa.types, 'is_string_view', lambda _: False)(source):
+            source = pa.string()
+        elif getattr(pa.types, 'is_binary_view', lambda _: False)(source):
+            source = pa.binary()
+    if pa.types.is_union(source):
+        # A Union can contain unused children of unrelated types. Qualify a
+        # possible match here; core validates only the active values exactly.
+        return any(_native_upsert_key_type_compatible(field.type, target) for 
field in source)
     if pa.types.is_null(source):
         return True
 
diff --git a/paimon-python/pypaimon/write/native_write.py 
b/paimon-python/pypaimon/write/native_write.py
index d7aef5ebfd..267694d695 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -225,19 +225,16 @@ class NativeTableWrite:
         data = normalize_arrow_strings(data)
         target = self._table_schema if data.schema.names == 
self._table_schema.names else self._schema
         data = normalize_arrow_write_nullability(data, target)
-        if self._write_cols is not None and arrow_schemas_compatible(
-                data.schema, self._table_schema, 
check_top_level_nullability=False,
-                allow_binary_compatibility=True):
-            # Python accepts either the table schema or the selected schema.
-            # Project the input before handing its actual write type to core.
-            data = data.select(self._write_cols)
-        if not arrow_schemas_compatible(
-                data.schema, self._schema, check_top_level_nullability=False,
-                allow_binary_compatibility=True):
+        compatible = dict(check_top_level_nullability=False, 
allow_binary_compatibility=True)
+        if not (arrow_schemas_compatible(data.schema, self._schema, 
**compatible)
+                or (self._write_cols is not None
+                    and arrow_schemas_compatible(data.schema, 
self._table_schema, **compatible))):
             raise ValueError(
                 "Input schema isn't consistent with table schema and write 
cols. "
                 f"Input schema is: {data.schema} Table schema is: 
{self._schema} "
                 f"Write cols is: {self._write_cols}")
+        # Full input can carry routing columns outside the physical write type.
+        # Core routes rows first and writes only the selected columns.
         return data
 
     def write_pandas(self, dataframe):
@@ -275,6 +272,18 @@ class NativeTableWrite:
         from pypaimon.write.row_kind import skip_write_row, with_row_kind
         if skip_write_row(self.table, values, row.get_row_kind()):
             return
+        if self._write_cols is not None:
+            routing = list(self.table.partition_keys)
+            if ((self.table.bucket_mode() == BucketMode.HASH_FIXED and 
self.table.options.bucket() > 1)
+                    or isinstance(self, NativePostponeFixedBucketTableWrite)):
+                routing.extend(self.table.table_schema.bucket_keys)
+            require_columns(values, routing, 'write_row')
+            if not set(routing).issubset(names):
+                retained = set(names) | set(routing)
+                names = self._table_schema.names
+                # Only routing and written values need transport. In 
particular,
+                # an unwritten Blob object must not be opened or encoded.
+                values = {name: values.get(name) if name in retained else None 
for name in names}
         try:
             data = (self._blob_rows.to_batch(self.table, [values], names)
                     if self._blob_rows is not None else
diff --git a/paimon-python/pypaimon/write/table_update.py 
b/paimon-python/pypaimon/write/table_update.py
index 1599de5ec9..ec881bf65e 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -57,6 +57,16 @@ from pypaimon.write.writer.append_only_data_writer import 
AppendOnlyDataWriter
 logger = logging.getLogger(__name__)
 
 
+def _authorize_update(table):
+    """Require the same whole-table grant as plan_all_files for fast paths."""
+    from pypaimon.catalog.catalog_exception import TableNoPermissionException
+    from pypaimon.read.table_scan import authorize
+
+    query_auth = table.catalog_environment.table_query_auth(table.options, 
table.identifier)
+    if authorize(table, query_auth, None) is not None:
+        raise TableNoPermissionException(table.identifier)
+
+
 def _filter_by_whole_file_shard(splits: List[DataSplit], sub_task_id: int, 
total_tasks: int) -> List[DataSplit]:
     list_ranges = []
     for split in splits:
@@ -568,6 +578,7 @@ class TableUpdate:
 
     def _delete_by_partition_filter(
             self, partition_filter: Predicate) -> List[CommitMessage]:
+        _authorize_update(self.table)
         snapshot = self.table.snapshot_manager().get_latest_snapshot()
         if snapshot is None:
             return []
diff --git a/paimon-python/pypaimon/write/table_write.py 
b/paimon-python/pypaimon/write/table_write.py
index 63b175e168..65c9996326 100644
--- a/paimon-python/pypaimon/write/table_write.py
+++ b/paimon-python/pypaimon/write/table_write.py
@@ -96,6 +96,8 @@ class TableWrite:
         if bucket is not None:
             return self._write_precomputed_bucket(data, bucket)
         data = self._prepare_arrow_data(data)
+        if data.num_rows == 0:
+            return
 
         for partition, bucket, row_indices in \
                 self.row_key_extractor.extract_partition_bucket_groups(data):
@@ -129,6 +131,11 @@ class TableWrite:
         self.file_store_write.roll_before_group_if_needed(row_count)
 
     def _write_partition_bucket_batch(self, partition, bucket, data):
+        write_cols = self.file_store_write.write_cols
+        if write_cols is not None:
+            # Keep routing columns available for full-schema inputs, then
+            # write only the columns and order recorded in the file metadata.
+            data = data.select(write_cols)
         self.file_store_write.write(partition, bucket, data)
 
     def _write_precomputed_bucket(self, data, bucket):
@@ -202,10 +209,16 @@ class TableWrite:
         return self.write_arrow_batch(record_batch)
 
     def with_write_type(self, write_cols: List[str]):
+        write_cols = list(write_cols)
         for col in write_cols:
             if col not in self.table_pyarrow_schema.names:
                 raise ValueError(f"Column {col} is not in table schema.")
-        if len(write_cols) == len(self.table_pyarrow_schema.names):
+        full_names = self.table_pyarrow_schema.names
+        # Java preserves the declared append column order in writeCols. PK
+        # writes still require the complete table layout in the Python buffer.
+        full_pk_type = (self.table.is_primary_key_table and len(write_cols) == 
len(full_names)
+                        and set(write_cols) == set(full_names))
+        if write_cols == full_names or full_pk_type:
             write_cols = None
         self.file_store_write.write_cols = write_cols
         return self

Reply via email to