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 bab76b6198 [python] Enable native Paimon branch writes (#10385)
bab76b6198 is described below

commit bab76b6198509c2f6d5413f3320d967b1fa7620a
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 5 09:56:45 2026 +0800

    [python] Enable native Paimon branch writes (#10385)
---
 paimon-python/pypaimon/api/rest_api.py             |   3 +-
 paimon-python/pypaimon/api/rest_util.py            |  10 +-
 paimon-python/pypaimon/branch/branch_manager.py    |   5 +-
 .../pypaimon/branch/filesystem_branch_manager.py   |   4 +-
 .../pypaimon/tests/api/test_branch_dto_serde.py    |   4 +-
 .../pypaimon/tests/api/test_partition_dto_serde.py |   2 +-
 .../pypaimon/tests/api/test_tag_dto_serde.py       |   4 +-
 .../pypaimon/tests/branch_manager_test.py          |   4 +-
 .../pypaimon/tests/native_branch_write_test.py     | 481 +++++++++++++++++++++
 paimon-python/pypaimon/tests/native_commit_test.py |   5 +-
 paimon-python/pypaimon/tests/rest/api_test.py      |  20 +
 paimon-python/pypaimon/tests/rest/rest_server.py   |  82 +++-
 paimon-python/pypaimon/write/native_commit.py      |   9 +-
 13 files changed, 589 insertions(+), 44 deletions(-)

diff --git a/paimon-python/pypaimon/api/rest_api.py 
b/paimon-python/pypaimon/api/rest_api.py
index 608b195974..b6ee8f4144 100755
--- a/paimon-python/pypaimon/api/rest_api.py
+++ b/paimon-python/pypaimon/api/rest_api.py
@@ -789,7 +789,8 @@ class RESTApi:
         if not table_name or not table_name.strip():
             raise ValueError("Table name cannot be None")
 
-        return database_name.strip(), table_name.strip()
+        # Validation must not rename a resource, including a branch suffix.
+        return database_name, table_name
 
 
 from pypaimon.catalog.catalog_exception import IllegalArgumentError
diff --git a/paimon-python/pypaimon/api/rest_util.py 
b/paimon-python/pypaimon/api/rest_util.py
index 2ccb406406..04a02ba531 100644
--- a/paimon-python/pypaimon/api/rest_util.py
+++ b/paimon-python/pypaimon/api/rest_util.py
@@ -16,7 +16,7 @@
 # under the License.
 
 from typing import Dict, Optional
-from urllib.parse import unquote
+from urllib.parse import quote_plus, unquote_plus
 
 from pypaimon.common.options import Options
 
@@ -24,14 +24,14 @@ from pypaimon.common.options import Options
 class RESTUtil:
     @staticmethod
     def encode_string(value: str) -> str:
-        import urllib.parse
-
-        return urllib.parse.quote(value)
+        # Java URLEncoder uses form encoding, including '+' for spaces and
+        # escaping '/' so one resource name stays one path segment.
+        return quote_plus(value, safe="*").replace("~", "%7E")
 
     @staticmethod
     def decode_string(encoded: str) -> str:
         """Decode URL-encoded string"""
-        return unquote(encoded)
+        return unquote_plus(encoded)
 
     @staticmethod
     def extract_prefix_map(
diff --git a/paimon-python/pypaimon/branch/branch_manager.py 
b/paimon-python/pypaimon/branch/branch_manager.py
index b7e0b443d7..ae0d160f17 100644
--- a/paimon-python/pypaimon/branch/branch_manager.py
+++ b/paimon-python/pypaimon/branch/branch_manager.py
@@ -143,7 +143,8 @@ class BranchManager:
         """
         if not branch or not branch.strip():
             return DEFAULT_MAIN_BRANCH
-        return branch.strip()
+        # Java preserves non-blank names; trimming would alias branch 
directories.
+        return branch
 
     @staticmethod
     def is_main_branch(branch: str) -> bool:
@@ -175,7 +176,7 @@ class BranchManager:
             )
         if not branch_name or not branch_name.strip():
             raise ValueError("Branch name is blank.")
-        if branch_name.strip().isdigit():
+        if branch_name.isdigit():
             raise ValueError(
                 f"Branch name cannot be pure numeric string but is 
'{branch_name}'."
             )
diff --git a/paimon-python/pypaimon/branch/filesystem_branch_manager.py 
b/paimon-python/pypaimon/branch/filesystem_branch_manager.py
index f191d29a63..855e30603e 100644
--- a/paimon-python/pypaimon/branch/filesystem_branch_manager.py
+++ b/paimon-python/pypaimon/branch/filesystem_branch_manager.py
@@ -61,9 +61,7 @@ class FileSystemBranchManager(BranchManager):
     @staticmethod
     def _normalize_branch(branch: str) -> str:
         """Normalize branch name."""
-        if not branch or branch == "main":
-            return "main"
-        return branch
+        return BranchManager.normalize_branch(branch)
 
     def _branch_directory(self) -> str:
         """Return the root directory of branch."""
diff --git a/paimon-python/pypaimon/tests/api/test_branch_dto_serde.py 
b/paimon-python/pypaimon/tests/api/test_branch_dto_serde.py
index 58d7139bcf..a211c7af69 100644
--- a/paimon-python/pypaimon/tests/api/test_branch_dto_serde.py
+++ b/paimon-python/pypaimon/tests/api/test_branch_dto_serde.py
@@ -124,10 +124,10 @@ class ResourcePathsBranchesTest(unittest.TestCase):
 
     def test_branch_url_url_encodes_branch_name(self):
         # RESTUtil.encode_string escapes characters that are not URL-safe;
-        # a space round-trips to ``%20``. Mirrors the existing tag-name path.
+        # a space round-trips to ``+``. Mirrors the existing tag-name path.
         self.assertEqual(
             self.paths.branch("db", "tbl", "release 1.0"),
-            "/v1/mock/databases/db/tables/tbl/branches/release%201.0",
+            "/v1/mock/databases/db/tables/tbl/branches/release+1.0",
         )
 
 
diff --git a/paimon-python/pypaimon/tests/api/test_partition_dto_serde.py 
b/paimon-python/pypaimon/tests/api/test_partition_dto_serde.py
index 7b5ec38f40..bbb91ab78d 100644
--- a/paimon-python/pypaimon/tests/api/test_partition_dto_serde.py
+++ b/paimon-python/pypaimon/tests/api/test_partition_dto_serde.py
@@ -96,7 +96,7 @@ class ResourcePathsPartitionsTest(unittest.TestCase):
         paths = ResourcePaths(prefix="mock")
         self.assertEqual(
             paths.partitions("my db", "my tbl"),
-            "/v1/mock/databases/my%20db/tables/my%20tbl/partitions",
+            "/v1/mock/databases/my+db/tables/my+tbl/partitions",
         )
 
 if __name__ == "__main__":
diff --git a/paimon-python/pypaimon/tests/api/test_tag_dto_serde.py 
b/paimon-python/pypaimon/tests/api/test_tag_dto_serde.py
index 421dbf8d7c..ddcfbb1be2 100644
--- a/paimon-python/pypaimon/tests/api/test_tag_dto_serde.py
+++ b/paimon-python/pypaimon/tests/api/test_tag_dto_serde.py
@@ -132,10 +132,10 @@ class ResourcePathsTagsTest(unittest.TestCase):
 
     def test_single_tag_url_url_encodes_tag_name(self):
         # RESTUtil.encode_string escapes characters that are not URL-safe.
-        # A space round-trips to ``%20``; this matches Java's ``RESTUtil``.
+        # A space round-trips to ``+``; this matches Java's ``RESTUtil``.
         paths = ResourcePaths(prefix="mock")
         url = paths.tag("db", "tbl", "release 1.0")
-        self.assertEqual(url, 
"/v1/mock/databases/db/tables/tbl/tags/release%201.0")
+        self.assertEqual(url, 
"/v1/mock/databases/db/tables/tbl/tags/release+1.0")
 
 
 if __name__ == "__main__":
diff --git a/paimon-python/pypaimon/tests/branch_manager_test.py 
b/paimon-python/pypaimon/tests/branch_manager_test.py
index d51d69b188..dc70797b76 100644
--- a/paimon-python/pypaimon/tests/branch_manager_test.py
+++ b/paimon-python/pypaimon/tests/branch_manager_test.py
@@ -44,7 +44,7 @@ class BranchManagerTest(unittest.TestCase):
         self.assertEqual(BranchManager.normalize_branch("  "), 
DEFAULT_MAIN_BRANCH)
         self.assertEqual(BranchManager.normalize_branch("main"), "main")
         self.assertEqual(BranchManager.normalize_branch("feature"), "feature")
-        self.assertEqual(BranchManager.normalize_branch("  feature  "), 
"feature")
+        self.assertEqual(BranchManager.normalize_branch("  feature  "), "  
feature  ")
 
     def test_branch_path(self):
         """Test branch_path method."""
@@ -87,6 +87,8 @@ class BranchManagerTest(unittest.TestCase):
         BranchManager.validate_branch("feature")
         BranchManager.validate_branch("develop")
         BranchManager.validate_branch("feature-branch-123")
+        BranchManager.validate_branch(" main ")
+        BranchManager.validate_branch(" 123 ")
 
     def test_fast_forward_validate_to_main(self):
         """Test fast_forward_validate rejects fast-forward to main."""
diff --git a/paimon-python/pypaimon/tests/native_branch_write_test.py 
b/paimon-python/pypaimon/tests/native_branch_write_test.py
new file mode 100644
index 0000000000..38de93fd4e
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_branch_write_test.py
@@ -0,0 +1,481 @@
+# 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.
+
+"""Branch writes follow Java's independent snapshot and shared-data layout."""
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.common.identifier import Identifier
+from pypaimon.common.options.core_options import MergeEngine
+from pypaimon.write.native_write import NativeTableWrite
+
+pytestmark = pytest.mark.native_plan
+_SCHEMA = pa.schema([('id', pa.int32()), ('value', pa.int32()), ('p', 
pa.string())])
+
+
+def _table(tmp_path, rest_catalog, rest, options=None, primary_keys=None):
+    catalog = rest_catalog if rest else CatalogFactory.create({'warehouse': 
str(tmp_path)})
+    catalog.create_database('default', True)
+    settings = {'file.format': 'parquet', 'write.native.enabled': 'true',
+                'commit.native.enabled': 'true', 'manifest.sidecar.enabled': 
'true',
+                'bucket': '1' if primary_keys else '-1'}
+    settings.update(options or {})
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        _SCHEMA, primary_keys=primary_keys or [], partition_keys=['p'], 
options=settings), False)
+    return catalog, catalog.get_table('default.t')
+
+
+def _prepare(builder, rows, checkpoint=None):
+    writer = builder.new_write()
+    assert isinstance(writer, NativeTableWrite)
+    try:
+        writer.write_arrow(rows if isinstance(rows, pa.Table)
+                           else pa.Table.from_pylist(rows, schema=_SCHEMA))
+        assert writer._python_writer is None
+        return writer.prepare_commit() if checkpoint is None else 
writer.prepare_commit(checkpoint)
+    finally:
+        writer.close()
+
+
+def _commit(builder, messages, checkpoint=None, overwrite=False):
+    commit = builder.new_commit()
+    try:
+        def publish():
+            if checkpoint is None:
+                commit.commit(messages)
+            else:
+                commit.commit(messages, checkpoint)
+        if builder.table.catalog_environment.supports_version_management:
+            with patch.object(commit.file_store_commit, 'overwrite' if 
overwrite else 'commit',
+                              side_effect=AssertionError('Python commit 
fallback')):
+                publish()
+        else:
+            publish()
+    finally:
+        commit.close()
+
+
+def _write(table, rows):
+    builder = (table.new_postpone_fixed_bucket_write_builder()
+               if table.options.bucket() == -2 else 
table.new_batch_write_builder())
+    _commit(builder, _prepare(builder, rows))
+
+
+def _branch(catalog, table, from_tag=True, name='dev'):
+    if from_tag:
+        catalog.create_tag(table.identifier, 'base')
+    catalog.create_branch(table.identifier, name, tag_name='base' if from_tag 
else None)
+    return catalog.get_table(Identifier('default', 't', branch=name))
+
+
+def _rows(table, native):
+    builder = table.copy({'scan.native-plan.enabled': str(native).lower(),
+                          'read.native.enabled': 
str(native).lower()}).new_read_builder()
+    scan = builder.new_scan()
+    splits = (scan.plan_for_write() if table.is_primary_key_table
+              and table.options.merge_engine() == MergeEngine.FIRST_ROW else 
scan.plan()).splits()
+    reader = builder.new_read()
+    if native:
+        with patch.object(reader, '_create_split_read', 
side_effect=AssertionError('Python read fallback')):
+            rows = reader.to_arrow(splits).to_pylist()
+    else:
+        rows = reader.to_arrow(splits).to_pylist()
+    return sorted(rows, key=lambda row: (row['p'], row['id'], row['value']))
+
+
+def _assert_rows(table, expected):
+    for native in (False, True):
+        assert _rows(table, native) == expected
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
[email protected]('from_tag', [False, True], ids=['empty', 'tagged'])
+def test_native_branch_append_isolates_main(tmp_path, native_rest_catalog, 
rest, from_tag):
+    catalog, main = _table(tmp_path, native_rest_catalog, rest)
+    seed = {'id': 1, 'value': 10, 'p': 'a'}
+    _write(main, [seed])
+    branch = _branch(catalog, main, from_tag)
+    _write(main, [{'id': 2, 'value': 200, 'p': 'a'}])
+    _write(branch, [{'id': 3, 'value': 30, 'p': 'b'}])
+    _assert_rows(main, [seed, {'id': 2, 'value': 200, 'p': 'a'}])
+    _assert_rows(branch, ([seed] if from_tag else []) + [{'id': 3, 'value': 
30, 'p': 'b'}])
+    assert main.snapshot_manager().get_latest_snapshot().id == 2
+    assert branch.snapshot_manager().get_latest_snapshot().id == (2 if 
from_tag else 1)
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
[email protected]('engine,branch_value,main_value', [
+    ('deduplicate', 20, 100), ('first-row', 10, 10),
+    ('partial-update', 20, 100), ('aggregation', 30, 110),
+])
+def test_native_branch_primary_key_merge_engines(
+        tmp_path, native_rest_catalog, rest, engine, branch_value, main_value):
+    options = {'merge-engine': engine}
+    if engine == 'aggregation':
+        options['fields.value.aggregate-function'] = 'sum'
+    catalog, main = _table(tmp_path, native_rest_catalog, rest, options, ['p', 
'id'])
+    _write(main, [{'id': 1, 'value': 10, 'p': 'a'}])
+    branch = _branch(catalog, main)
+    _write(main, [{'id': 1, 'value': 100, 'p': 'a'}])
+    _write(branch, [{'id': 1, 'value': 20, 'p': 'a'}])
+    _assert_rows(main, [{'id': 1, 'value': main_value, 'p': 'a'}])
+    _assert_rows(branch, [{'id': 1, 'value': branch_value, 'p': 'a'}])
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
[email protected]('routing', ['dynamic', 'cross-partition', 'postpone'])
+def test_native_branch_bucket_routing(tmp_path, native_rest_catalog, rest, 
routing):
+    options = {'bucket': '-2' if routing == 'postpone' else '-1',
+               'dynamic-bucket.target-row-num': '1', 
'postpone.default-bucket-num': '2'}
+    keys = ['id'] if routing == 'cross-partition' else ['p', 'id']
+    catalog, main = _table(tmp_path, native_rest_catalog, rest, options, keys)
+    original = {'id': 1, 'value': 10, 'p': 'a'}
+    _write(main, [original])
+    branch = _branch(catalog, main)
+    _write(main, [{'id': 2, 'value': 200, 'p': 'b'}])
+    _write(branch, [{'id': 1, 'value': 20, 'p': 'b'}, {'id': 3, 'value': 30, 
'p': 'b'}])
+    _assert_rows(main, [original, {'id': 2, 'value': 200, 'p': 'b'}])
+    expected = [] if routing == 'cross-partition' else [original]
+    _assert_rows(branch, expected + [
+        {'id': 1, 'value': 20, 'p': 'b'}, {'id': 3, 'value': 30, 'p': 'b'}])
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
[email protected]('data_evolution', [False, True], ids=['append', 
'data-evolution'])
+def test_native_branch_overwrite(tmp_path, native_rest_catalog, rest, 
data_evolution):
+    options = ({'row-tracking.enabled': 'true', 'data-evolution.enabled': 
'true'}
+               if data_evolution else {})
+    catalog, main = _table(tmp_path, native_rest_catalog, rest, options)
+    original = {'id': 1, 'value': 10, 'p': 'a'}
+    _write(main, [original])
+    branch = _branch(catalog, main)
+    builder = branch.new_batch_write_builder().overwrite({'p': 'a'})
+    changed = {'id': 2, 'value': 20, 'p': 'a'}
+    _commit(builder, _prepare(builder, [changed]), overwrite=True)
+    _assert_rows(main, [original])
+    _assert_rows(branch, [changed])
+    assert main.snapshot_manager().get_latest_snapshot().id == 1
+    assert branch.snapshot_manager().get_latest_snapshot().id == 2
+    if data_evolution:
+        assert main.snapshot_manager().get_latest_snapshot().next_row_id == 1
+        assert branch.snapshot_manager().get_latest_snapshot().next_row_id == 2
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
+def test_native_branch_stream_checkpoint_histories(tmp_path, 
native_rest_catalog, rest):
+    catalog, main = _table(tmp_path, native_rest_catalog, rest, 
primary_keys=['p', 'id'])
+    _write(main, [{'id': 1, 'value': 10, 'p': 'a'}])
+    branch = _branch(catalog, main)
+    for target, value in ((main, 100), (branch, 20)):
+        builder = 
target.new_stream_write_builder()._with_commit_user('same-writer')
+        messages = _prepare(builder, [{'id': 1, 'value': value, 'p': 'a'}], 
checkpoint=7)
+        _commit(builder, messages, checkpoint=7)
+        snapshot = target.snapshot_manager().get_latest_snapshot()
+        assert snapshot.id == 2
+        assert snapshot.commit_identifier == 7
+    _assert_rows(main, [{'id': 1, 'value': 100, 'p': 'a'}])
+    _assert_rows(branch, [{'id': 1, 'value': 20, 'p': 'a'}])
+
+
+def _update_value(table, value):
+    from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+    builder = table.new_batch_write_builder()
+    data = pa.table({'_ROW_ID': pa.array([0], type=pa.int64()),
+                     'value': pa.array([value], type=pa.int32())})
+    with patch.object(TableUpdateByRowId, 'update_columns',
+                      side_effect=AssertionError('Python update fallback')):
+        messages = 
builder.new_update().with_update_type(['value']).update_by_arrow_with_row_id(data)
+    assert messages
+    return builder, messages
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
+def test_native_branch_row_id_conflicts_are_scoped(tmp_path, 
native_rest_catalog, rest):
+    catalog, main = _table(tmp_path, native_rest_catalog, rest, {
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'})
+    _write(main, [{'id': 1, 'value': 10, 'p': 'a'}])
+    branch = _branch(catalog, main)
+    main_builder, main_messages = _update_value(main, 100)
+    branch_builder, branch_messages = _update_value(branch, 20)
+    _commit(branch_builder, branch_messages)
+    _commit(main_builder, main_messages)
+    _assert_rows(main, [{'id': 1, 'value': 100, 'p': 'a'}])
+    _assert_rows(branch, [{'id': 1, 'value': 20, 'p': 'a'}])
+    stale_builder, stale_messages = _update_value(branch, 21)
+    fresh_builder, fresh_messages = _update_value(branch, 22)
+    _commit(fresh_builder, fresh_messages)
+    with pytest.raises(Exception, match='conflict|Conflict|overlap'):
+        _commit(stale_builder, stale_messages)
+    assert branch.snapshot_manager().get_latest_snapshot().id == 3
+    _assert_rows(branch, [{'id': 1, 'value': 22, 'p': 'a'}])
+    _assert_rows(main, [{'id': 1, 'value': 100, 'p': 'a'}])
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
[email protected]('from_tag', [False, True], ids=['empty', 'tagged'])
+def test_native_branch_deletion_vectors_and_row_id_allocation(
+        tmp_path, native_rest_catalog, rest, from_tag):
+    from pypaimon.write.table_delete import TableDeleteByRowId
+
+    catalog, main = _table(tmp_path, native_rest_catalog, rest, {
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'deletion-vectors.enabled': 'true'})
+    original = [{'id': 1, 'value': 10, 'p': 'a'}, {'id': 2, 'value': 20, 'p': 
'a'}]
+    _write(main, original)
+    branch = _branch(catalog, main, from_tag)
+    if not from_tag:
+        _write(branch, original)
+    builder = branch.new_batch_write_builder()
+    with patch.object(TableDeleteByRowId, 'delete',
+                      side_effect=AssertionError('Python delete fallback')):
+        messages = builder.new_update().delete_by_row_id([0])
+    _commit(builder, messages)
+    _assert_rows(main, original)
+    _assert_rows(branch, [original[1]])
+    assert branch.snapshot_manager().get_latest_snapshot().next_row_id == 2
+    third = {'id': 3, 'value': 30, 'p': 'a'}
+    _write(branch, [third])
+    _assert_rows(branch, [original[1], third])
+    assert branch.snapshot_manager().get_latest_snapshot().next_row_id == 3
+    assert main.snapshot_manager().get_latest_snapshot().next_row_id == 2
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
+def test_native_branch_schema_evolution_is_independent(tmp_path, 
native_rest_catalog, rest):
+    from pypaimon.schema.data_types import AtomicType
+    from pypaimon.schema.schema_change import SchemaChange
+
+    catalog, main = _table(tmp_path, native_rest_catalog, rest)
+    seed = {'id': 1, 'value': 10, 'p': 'a'}
+    _write(main, [seed])
+    branch = _branch(catalog, main)
+    catalog.alter_table(main.identifier, [SchemaChange.add_column('main_only', 
AtomicType('INT'))], False)
+    main = catalog.get_table(main.identifier)
+    main_schema = _SCHEMA.append(pa.field('main_only', pa.int32()))
+    _write(main, pa.Table.from_pylist(
+        [{'id': 2, 'value': 200, 'p': 'a', 'main_only': 7}], 
schema=main_schema))
+    _write(branch, [{'id': 3, 'value': 30, 'p': 'b'}])
+    assert main.snapshot_manager().get_latest_snapshot().schema_id == 1
+    assert branch.snapshot_manager().get_latest_snapshot().schema_id == 0
+    assert 'main_only' not in branch.field_names
+    catalog.alter_table(branch.identifier, [
+        SchemaChange.add_column('dev_only', AtomicType('STRING')),
+        SchemaChange.set_option('target-file-row-num', '1')], False)
+    branch = catalog.get_table(branch.identifier)
+    branch_schema = _SCHEMA.append(pa.field('dev_only', pa.string()))
+    _write(branch, pa.Table.from_pylist(
+        [{'id': 4, 'value': 40, 'p': 'b', 'dev_only': 'dev'}], 
schema=branch_schema))
+    _assert_rows(branch, [dict(seed, dev_only=None),
+                          {'id': 3, 'value': 30, 'p': 'b', 'dev_only': None},
+                          {'id': 4, 'value': 40, 'p': 'b', 'dev_only': 'dev'}])
+    _assert_rows(main, [dict(seed, main_only=None),
+                        {'id': 2, 'value': 200, 'p': 'a', 'main_only': 7}])
+    assert 'dev_only' not in main.field_names
+    assert branch.snapshot_manager().get_latest_snapshot().schema_id == 
branch.table_schema.id
+    assert main.snapshot_manager().get_latest_snapshot().schema_id == 1
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
+def test_native_branch_predicate_upsert_and_batch_updates(tmp_path, 
native_rest_catalog, rest):
+    from pypaimon.read.table_read import TableRead
+    from pypaimon.write.table_update import BatchTableUpdate
+    from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+    from pypaimon.write.table_delete import TableDeleteByRowId
+
+    catalog, main = _table(tmp_path, native_rest_catalog, rest, {
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'deletion-vectors.enabled': 'true', 'target-file-row-num': '1'})
+    original = [{'id': 1, 'value': 10, 'p': 'a'}, {'id': 2, 'value': 20, 'p': 
'b'}]
+    _write(main, original)
+    branch = _branch(catalog, main)
+    predicate = branch.new_read_builder().new_predicate_builder().equal('id', 
1)
+    builder = branch.new_batch_write_builder()
+    with patch.object(TableRead, 'to_arrow', 
side_effect=AssertionError('Python predicate scan fallback')):
+        messages = builder.new_update().update_by_predicate(
+            predicate, {'value': lambda batch: pa.compute.add(batch['value'], 
1)}, ['value'])
+    _commit(builder, messages)
+    builder = branch.new_batch_write_builder()
+    with patch.object(TableUpsertByKey, '_upsert_partition',
+                      side_effect=AssertionError('Python upsert fallback')):
+        messages = 
builder.new_update().upsert_by_arrow_with_key(pa.Table.from_pylist([
+            {'id': 1, 'value': 12, 'p': 'a'}, {'id': 3, 'value': 30, 'p': 
'c'}], schema=_SCHEMA), ['id'])
+    _commit(builder, messages)
+    # Partition groups may be published in either order. Match logical rows
+    # to their actual IDs rather than assuming a HashMap iteration order.
+    reader = branch.copy({'scan.native-plan.enabled': 'true', 
'read.native.enabled': 'true'})
+    read = reader.new_read_builder().with_projection(['id', '_ROW_ID'])
+    ids = {row['id']: row['_ROW_ID'] for row in read.new_read().to_arrow(
+        read.new_scan().plan().splits()).to_pylist()}
+    builder = branch.new_batch_write_builder()
+    with patch.object(BatchTableUpdate, '_update_by_arrow_batches_with_row_id',
+                      side_effect=AssertionError('Python grouped update 
fallback')):
+        messages = 
builder.new_update().with_update_type(['value']).update_by_arrow_batches_with_row_id([
+            pa.table({'_ROW_ID': [ids[1]], 'value': [13]}),
+            pa.table({'_ROW_ID': [ids[2]], 'value': [21]})])
+    _commit(builder, messages)
+    builder = branch.new_batch_write_builder()
+    with patch.object(TableDeleteByRowId, 'delete', 
side_effect=AssertionError('Python delete fallback')):
+        messages = builder.new_update().delete_by_predicate(
+            branch.new_read_builder().new_predicate_builder().equal('id', 2))
+    _commit(builder, messages)
+    _assert_rows(main, original)
+    _assert_rows(branch, [{'id': 1, 'value': 13, 'p': 'a'}, {'id': 3, 'value': 
30, 'p': 'c'}])
+    assert branch.snapshot_manager().get_latest_snapshot().next_row_id == 3
+    assert main.snapshot_manager().get_latest_snapshot().next_row_id == 2
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
+def 
test_native_branch_blob_append_and_overwrite_leave_shared_payloads_readable(tmp_path,
 native_rest_catalog, rest):
+    from pypaimon.schema.data_types import AtomicType, DataField
+
+    catalog = native_rest_catalog if rest else 
CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    options = {'file.format': 'parquet', 'write.native.enabled': 'true',
+               'commit.native.enabled': 'true', 'manifest.sidecar.enabled': 
'true',
+               'row-tracking.enabled': 'true', 'data-evolution.enabled': 
'true',
+               'blob.target-file-size': '1 B'}
+    fields = [DataField(0, 'id', AtomicType('INT')), DataField(1, 'value', 
AtomicType('BLOB')),
+              DataField(2, 'p', AtomicType('STRING'))]
+    catalog.create_table('default.t', Schema(fields=fields, 
partition_keys=['p'], options=options), False)
+    main = catalog.get_table('default.t')
+    schema = pa.schema([('id', pa.int32()), ('value', pa.large_binary()), 
('p', pa.string())])
+    seed = {'id': 1, 'value': b'original', 'p': 'a'}
+    _write(main, pa.Table.from_pylist([seed], schema=schema))
+    branch = _branch(catalog, main)
+    builder = branch.new_batch_write_builder()
+    added = {'id': 2, 'value': b'branch', 'p': 'a'}
+    messages = _prepare(builder, pa.Table.from_pylist([added], schema=schema))
+    assert any(file.file_name.endswith('.blob') for message in messages for 
file in message.new_files)
+    _commit(builder, messages)
+    assert _rows(main, True) == [seed]
+    assert _rows(branch, True) == [seed, added]
+    builder = branch.new_batch_write_builder().overwrite({'p': 'a'})
+    replacement = {'id': 2, 'value': b'replacement', 'p': 'a'}
+    _commit(builder, _prepare(builder, pa.Table.from_pylist([replacement], 
schema=schema)), overwrite=True)
+    assert _rows(branch, True) == [replacement]
+    assert _rows(main, True) == [seed]
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
+def test_native_branch_abort_removes_only_unpublished_output(tmp_path, 
native_rest_catalog, rest):
+    catalog, main = _table(tmp_path, native_rest_catalog, rest)
+    seed = {'id': 1, 'value': 10, 'p': 'a'}
+    _write(main, [seed])
+    branch = _branch(catalog, main)
+    builder = branch.new_batch_write_builder()
+    messages = _prepare(builder, [{'id': 2, 'value': 20, 'p': 'a'}])
+    paths = [file.file_path for message in messages for file in 
message.new_files]
+    assert paths and all(branch.file_io.exists(path) for path in paths)
+    commit = builder.new_commit()
+    try:
+        if rest:
+            with patch.object(commit.file_store_commit, 'abort',
+                              side_effect=AssertionError('Python abort 
fallback')):
+                commit.abort(messages)
+        else:
+            commit.abort(messages)
+    finally:
+        commit.close()
+    assert all(not branch.file_io.exists(path) for path in paths)
+    assert branch.snapshot_manager().get_latest_snapshot().id == 1
+    _assert_rows(main, [seed])
+    _assert_rows(branch, [seed])
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
+def test_native_branch_lifecycle_preserves_histories(tmp_path, 
native_rest_catalog, rest):
+    from pypaimon.catalog.catalog_exception import TableNotExistException
+
+    catalog, main = _table(tmp_path, native_rest_catalog, rest)
+    seed = {'id': 1, 'value': 10, 'p': 'a'}
+    added = {'id': 2, 'value': 20, 'p': 'a'}
+    _write(main, [seed])
+    branch = _branch(catalog, main)
+    _write(branch, [added])
+    catalog.rename_branch(main.identifier, 'dev', 'renamed')
+    catalog.rename_table(main.identifier, Identifier('default', 'renamed'))
+    main = catalog.get_table('default.renamed')
+    branch = catalog.get_table(Identifier('default', 'renamed', 
branch='renamed'))
+    _assert_rows(main, [seed])
+    _assert_rows(branch, [seed, added])
+    assert catalog.list_branches(main.identifier) == ['renamed']
+    assert catalog.get_tag(main.identifier, 'base').snapshot.id == 1
+    with pytest.raises(TableNotExistException):
+        catalog.get_table('default.t')
+    third = {'id': 3, 'value': 30, 'p': 'b'}
+    _write(branch, [third])
+    _assert_rows(branch, [seed, added, third])
+    catalog.drop_branch(main.identifier, 'renamed')
+    assert catalog.list_branches(main.identifier) == []
+    _assert_rows(main, [seed])
+    catalog.create_branch(main.identifier, 'renamed')
+    empty = catalog.get_table(Identifier('default', 'renamed', 
branch='renamed'))
+    _assert_rows(empty, [])
+    _write(empty, [added])
+    _assert_rows(empty, [added])
+    assert empty.snapshot_manager().get_latest_snapshot().id == 1
+    _assert_rows(main, [seed])
+
+
[email protected]('name', ['main', '', ' ', '123'])
+def test_native_rest_branch_invalid_names_preserve_main(tmp_path, 
native_rest_catalog, name):
+    from pypaimon.api.rest_exception import BadRequestException
+    from pypaimon.catalog.catalog_exception import IllegalArgumentError
+
+    catalog, main = _table(tmp_path, native_rest_catalog, True)
+    seed = {'id': 1, 'value': 10, 'p': 'a'}
+    _write(main, [seed])
+    for operation in (lambda: catalog.create_branch(main.identifier, name),
+                      lambda: catalog.drop_branch(main.identifier, name)):
+        with pytest.raises((BadRequestException, IllegalArgumentError, 
ValueError)):
+            operation()
+        _assert_rows(main, [seed])
+    branch = _branch(catalog, main)
+    with pytest.raises((BadRequestException, IllegalArgumentError, 
ValueError)):
+        catalog.rename_branch(main.identifier, 'dev', name)
+    _assert_rows(branch, [seed])
+    _assert_rows(main, [seed])
+    assert main.schema_manager.latest().id == 0
+    assert catalog.list_branches(main.identifier) == ['dev']
+
+
[email protected]('rest', [False, True], ids=['filesystem', 'rest'])
[email protected]('name', [' dev ', ' main ', ' 123 ', 'dev+plus'])
+def test_native_branch_names_preserve_whitespace(tmp_path, 
native_rest_catalog, rest, name):
+    catalog, main = _table(tmp_path, native_rest_catalog, rest)
+    seed = {'id': 1, 'value': 10, 'p': 'a'}
+    _write(main, [seed])
+    dev = _branch(catalog, main)
+    normal = {'id': 2, 'value': 20, 'p': 'a'}
+    _write(dev, [normal])
+    catalog.create_branch(main.identifier, name, tag_name='base')
+    branch = catalog.get_table(Identifier('default', 't', branch=name))
+    assert branch.current_branch() == name
+    changed = {'id': 3, 'value': 30, 'p': 'a'}
+    _write(branch, [changed])
+    _assert_rows(branch, [seed, changed])
+    _assert_rows(dev, [seed, normal])
+    _assert_rows(main, [seed])
+    assert set(catalog.list_branches(main.identifier)) == {'dev', name}
+    catalog.drop_branch(main.identifier, name)
+    _assert_rows(dev, [seed, normal])
+    _assert_rows(main, [seed])
+    assert catalog.list_branches(main.identifier) == ['dev']
diff --git a/paimon-python/pypaimon/tests/native_commit_test.py 
b/paimon-python/pypaimon/tests/native_commit_test.py
index 4757c670f0..85b72f3ec7 100644
--- a/paimon-python/pypaimon/tests/native_commit_test.py
+++ b/paimon-python/pypaimon/tests/native_commit_test.py
@@ -22,7 +22,6 @@ import pytest
 
 from pypaimon import CatalogFactory, Schema
 from pypaimon.catalog.catalog_environment import CatalogEnvironment
-from pypaimon.common.identifier import Identifier
 from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.common.options.options import Options
 from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
@@ -530,7 +529,7 @@ def test_disabled_option_never_initializes_native(tmp_path):
     commit.close()
 
 
[email protected]('kind', ['custom-env', 'branch', 'custom-io'])
[email protected]('kind', ['custom-env', 'custom-io'])
 def test_incompatible_publication_environment_is_not_reconstructed(
         tmp_path, native_rest_catalog, kind):
     table = _table(tmp_path, catalog=native_rest_catalog)
@@ -538,8 +537,6 @@ def 
test_incompatible_publication_environment_is_not_reconstructed(
         class CustomEnvironment(CatalogEnvironment):
             pass
         table.catalog_environment = CustomEnvironment()
-    elif kind == 'branch':
-        table.identifier = Identifier('default', 't', branch='dev')
     else:
         table.file_io = Mock()
     with patch('pypaimon.write.native_commit.native_commit_available', 
return_value=True), \
diff --git a/paimon-python/pypaimon/tests/rest/api_test.py 
b/paimon-python/pypaimon/tests/rest/api_test.py
index 94ab827cbf..a09a3b9c89 100644
--- a/paimon-python/pypaimon/tests/rest/api_test.py
+++ b/paimon-python/pypaimon/tests/rest/api_test.py
@@ -38,6 +38,26 @@ from pypaimon.tests.rest.rest_server import RESTCatalogServer
 
 class ApiTest(unittest.TestCase):
 
+    def test_resource_names_match_java_form_url_encoding(self):
+        from pypaimon.api.rest_util import RESTUtil
+        from pypaimon.api.resource_paths import ResourcePaths
+
+        # Golden values from Java URLEncoder / URLDecoder, not from Python's
+        # encoder. Spaces and literal '+' characters must remain distinct.
+        cases = [
+            ("a b", "a+b"),
+            ("a+b", "a%2Bb"),
+            ("t$branch_ dev ", "t%24branch_+dev+"),
+            ("*~/.?&=", "*%7E%2F.%3F%26%3D"),
+            ("分支", "%E5%88%86%E6%94%AF"),
+        ]
+        for value, encoded in cases:
+            with self.subTest(value=value):
+                self.assertEqual(RESTUtil.encode_string(value), encoded)
+                self.assertEqual(RESTUtil.decode_string(encoded), value)
+                self.assertEqual(ResourcePaths("test").table("db", value),
+                                 "/v1/test/databases/db/tables/" + encoded)
+
     def test_parse_data(self):
         simple_type_test_cases = [
             "DECIMAL",
diff --git a/paimon-python/pypaimon/tests/rest/rest_server.py 
b/paimon-python/pypaimon/tests/rest/rest_server.py
index 1c5826b03f..b7769eb22f 100755
--- a/paimon-python/pypaimon/tests/rest/rest_server.py
+++ b/paimon-python/pypaimon/tests/rest/rest_server.py
@@ -378,8 +378,12 @@ class RESTCatalogServer:
                 rename_request = JSON.from_json(data, RenameTableRequest)
                 source_table = rename_request.source
                 destination_table = rename_request.destination
-                source = 
self.table_metadata_store.get(source_table.get_full_name())
-                
self.table_metadata_store.update({destination_table.get_full_name(): source})
+                source_name = source_table.get_full_name()
+                destination_name = destination_table.get_full_name()
+                if source_name not in self.table_metadata_store:
+                    raise TableNotExistException(source_table)
+                if destination_name in self.table_metadata_store:
+                    raise TableAlreadyExistException(destination_table)
                 source_table_dir = (Path(self.data_path) / self.warehouse
                                     / source_table.get_database_name() / 
source_table.get_object_name())
                 destination_table_dir = (Path(self.data_path) / self.warehouse
@@ -387,7 +391,12 @@ class RESTCatalogServer:
                 if not source_table_dir.exists():
                     destination_table_dir.mkdir(parents=True)
                 else:
+                    destination_table_dir.parent.mkdir(parents=True, 
exist_ok=True)
                     source_table_dir.rename(destination_table_dir)
+                for store in (self.table_metadata_store, self.branch_store, 
self.tag_store,
+                              self.table_latest_snapshot_store, 
self.table_partitions_store):
+                    if source_name in store:
+                        store[destination_name] = store.pop(source_name)
                 return self._mock_response("", 200)
 
             # Global functions endpoint (catalog-scoped)
@@ -512,6 +521,9 @@ class RESTCatalogServer:
                 ErrorResponse.RESOURCE_TYPE_DEFINITION, e.name, str(e), 404
             )
             return self._mock_response(response, 404)
+        except ValueError as e:
+            response = ErrorResponse(None, None, str(e), 400)
+            return self._mock_response(response, 400)
         except Exception as e:
             self.logger.error(f"Unexpected error: {e}")
             response = ErrorResponse(None, None, str(e), 500)
@@ -876,6 +888,11 @@ class RESTCatalogServer:
 
     # ======================= Branch Handlers ================================
 
+    @staticmethod
+    def _validate_branch_name(branch_name):
+        from pypaimon.branch.branch_manager import BranchManager
+        BranchManager.validate_branch(branch_name)
+
     def _branches_handle(self, method: str, data: str,
                          identifier: Identifier) -> Tuple[str, int]:
         """Handle the table-scoped branches collection (POST create / GET 
list)."""
@@ -884,18 +901,26 @@ class RESTCatalogServer:
 
         if method == "POST":
             request = JSON.from_json(data, CreateBranchRequest)
+            self._validate_branch_name(request.branch)
             store = self.branch_store.setdefault(identifier.get_full_name(), 
set())
             if request.branch in store:
                 raise BranchAlreadyExistException(request.branch)
 
+            schema_id = 
self.table_metadata_store[identifier.get_full_name()].schema.id
             if request.from_tag is not None:
                 tags = self.tag_store.get(identifier.get_full_name(), {})
                 if request.from_tag not in tags:
                     raise TagNotExistException(request.from_tag)
                 snapshot = tags[request.from_tag].snapshot
                 if snapshot is not None:
+                    schema_id = snapshot.schema_id
                     self._write_snapshot_files(
                         identifier, snapshot, None, request.branch)
+            manager = self._schema_manager(identifier)
+            target = manager.copy_with_branch(request.branch)
+            for schema in manager.list_all():
+                if schema.id <= schema_id:
+                    target.commit(schema)
             store.add(request.branch)
             return self._mock_response("", 200)
 
@@ -914,8 +939,11 @@ class RESTCatalogServer:
         store = self.branch_store.get(identifier.get_full_name(), set())
 
         if method == "DELETE":
+            self._validate_branch_name(branch_name)
             if branch_name not in store:
                 raise BranchNotExistException(branch_name)
+            manager = self._schema_manager(identifier, branch_name)
+            manager.file_io.delete(manager.branch_path, recursive=True)
             store.discard(branch_name)
             return self._mock_response("", 200)
         return self._mock_response(ErrorResponse(None, None, "Method Not 
Allowed", 405), 405)
@@ -931,8 +959,13 @@ class RESTCatalogServer:
         if from_branch not in store:
             raise BranchNotExistException(from_branch)
         request = JSON.from_json(data, RenameBranchRequest)
+        self._validate_branch_name(from_branch)
+        self._validate_branch_name(request.to_branch)
         if request.to_branch in store:
             raise BranchAlreadyExistException(request.to_branch)
+        source = self._schema_manager(identifier, from_branch)
+        target = self._schema_manager(identifier, request.to_branch)
+        Path(source.branch_path).rename(target.branch_path)
         store.discard(from_branch)
         store.add(request.to_branch)
         return self._mock_response("", 200)
@@ -1035,7 +1068,8 @@ class RESTCatalogServer:
         if method == "GET":
             if identifier.get_full_name() not in self.table_metadata_store:
                 raise TableNotExistException(identifier)
-            table_metadata = 
self.table_metadata_store[identifier.get_full_name()]
+            table_metadata = self._branch_table_metadata(
+                identifier, (response_identifier or 
identifier).get_branch_name())
             table_path = (f'file://{self.data_path}/{self.warehouse}/'
                           
f'{identifier.get_database_name()}/{identifier.get_object_name()}')
             schema = table_metadata.schema.to_schema()
@@ -1045,7 +1079,7 @@ class RESTCatalogServer:
         elif method == "POST":
             # Alter table
             request_body = JSON.from_json(data, AlterTableRequest)
-            self._alter_table_impl(identifier, request_body.changes)
+            self._alter_table_impl(response_identifier or identifier, 
request_body.changes)
             return self._mock_response("", 200)
         elif method == "DELETE":
             # Drop table
@@ -1296,10 +1330,7 @@ class RESTCatalogServer:
         from pypaimon.common.options.options import Options
         from pypaimon.table.file_store_table import FileStoreTable
 
-        table_metadata = 
self.table_metadata_store.get(identifier.get_full_name())
-        if table_metadata is None:
-            raise TableNotExistException(identifier)
-
+        table_metadata = self._branch_table_metadata(identifier, branch)
         table_schema = table_metadata.schema
         table_path = (
             f'file://{self.data_path}/{self.warehouse}/'
@@ -1457,9 +1488,28 @@ class RESTCatalogServer:
 
         return '^' + ''.join(regex) + '$'
 
+    def _schema_manager(self, identifier: Identifier, branch: str = None):
+        table_path = (Path(self.data_path) / self.warehouse /
+                      identifier.get_database_name() / 
identifier.get_table_name())
+        return SchemaManager(self._get_file_io(), str(table_path), branch or 
'main')
+
+    def _branch_table_metadata(self, identifier: Identifier, branch: str = 
None):
+        base = Identifier.create(identifier.get_database_name(), 
identifier.get_table_name())
+        metadata = self.table_metadata_store.get(base.get_full_name())
+        if metadata is None:
+            raise TableNotExistException(base)
+        if branch is None or branch == 'main':
+            return metadata
+        if branch not in self.branch_store.get(base.get_full_name(), set()):
+            raise BranchNotExistException(branch)
+        schema = self._schema_manager(base, branch).latest()
+        if schema is None:
+            raise BranchNotExistException(branch)
+        return TableMetadata(schema=schema, uuid=metadata.uuid, 
is_external=metadata.is_external)
+
     def _alter_table_impl(self, identifier: Identifier, changes: List) -> None:
-        if identifier.get_full_name() not in self.table_metadata_store:
-            raise TableNotExistException(identifier)
+        branch = identifier.get_branch_name()
+        table_metadata = self._branch_table_metadata(identifier, branch)
 
         schema_changes = []
         for change in changes:
@@ -1471,13 +1521,7 @@ class RESTCatalogServer:
             else:
                 schema_changes.append(change)
 
-        table_metadata = self.table_metadata_store[identifier.get_full_name()]
-
-        table_path = (
-            Path(self.data_path) / self.warehouse /
-            identifier.get_database_name() / identifier.get_object_name()
-        )
-        schema_manager = SchemaManager(self._get_file_io(), str(table_path))
+        schema_manager = self._schema_manager(identifier, branch)
         new_schema = schema_manager.commit_changes(schema_changes)
 
         updated_metadata = TableMetadata(
@@ -1485,7 +1529,9 @@ class RESTCatalogServer:
             is_external=table_metadata.is_external,
             uuid=table_metadata.uuid
         )
-        self.table_metadata_store[identifier.get_full_name()] = 
updated_metadata
+        if branch is None or branch == 'main':
+            base = Identifier.create(identifier.get_database_name(), 
identifier.get_table_name())
+            self.table_metadata_store[base.get_full_name()] = updated_metadata
 
     def _get_file_io(self):
         """Get FileIO instance for SchemaManager"""
diff --git a/paimon-python/pypaimon/write/native_commit.py 
b/paimon-python/pypaimon/write/native_commit.py
index 12f6e36cb5..6a12343bbd 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -113,9 +113,10 @@ def _native_rest_table(table, schema_json):
     catalog_options = _catalog_context_options(table)
     catalog_options['metastore'] = 'rest'
     native_table = NativeCatalog(catalog_options).get_table((
-        table.identifier.get_database_name(), 
table.identifier.get_table_name()))
+        table.identifier.get_database_name(), 
table.identifier.get_object_name()))
     if (native_table.location() != table.table_path
-            or native_table.rest_table_uuid() != 
table.catalog_environment.uuid):
+            or native_table.rest_table_uuid() != table.catalog_environment.uuid
+            or native_table.branch() != table.current_branch()):
         return None
     return native_table.copy_with_resolved_schema(schema_json)
 
@@ -128,11 +129,9 @@ def create_native_write_table(table):
     from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
     from pypaimon.table.file_store_table import FileStoreTable
 
-    # Native writes currently require the main branch.
     environment = table.catalog_environment
     if (type(table) is not FileStoreTable
             or type(environment) is not CatalogEnvironment
-            or table.current_branch() != 'main'
             or table.options.query_auth_enabled):
         return None
 
@@ -161,7 +160,7 @@ def create_native_write_table(table):
     return NativeTable.from_resolved_schema(
         table.table_path, schema_json,
         database=table.identifier.get_database_name(),
-        table=table.identifier.get_table_name(),
+        table=table.identifier.get_table_name(), branch=table.current_branch(),
         options=file_io_options)
 
 

Reply via email to