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 dba5e030d0 [python] Support TableUpdate.merge_into in PyPaimon (#8416)
dba5e030d0 is described below

commit dba5e030d052b96d87c3170268f912599cf1ed85
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jul 1 20:23:33 2026 +0800

    [python] Support TableUpdate.merge_into in PyPaimon (#8416)
    
    Adds single-process PyPaimon support for `TableUpdate.merge_into` on
    data-evolution tables. The new API prepares commit messages through the
    existing batch and stream update builders, so callers commit it the same
    way as other PyPaimon update operations.
---
 .github/workflows/check-licensing.yml              |   6 +-
 .github/workflows/e2e-tests-flink-1.x.yml          |   6 +
 .github/workflows/e2e-tests-flink-2.x-jdk11.yml    |   8 +-
 .github/workflows/utitcase-flink-1.x-common.yml    |   8 +-
 .github/workflows/utitcase-flink-1.x-others.yml    |   8 +-
 .github/workflows/utitcase-flink-2.x-jdk11.yml     |   8 +-
 .github/workflows/utitcase-jdk11.yml               |   8 +-
 .github/workflows/utitcase-spark-3.x.yml           |   8 +-
 .github/workflows/utitcase-spark-4.x.yml           |   8 +-
 .github/workflows/utitcase.yml                     |   8 +-
 docs/docs/pypaimon/data-evolution.md               | 348 ++++-------
 docs/docs/pypaimon/ray-data.md                     |  12 +-
 .../pypaimon/ray/data_evolution_merge_into.py      |  93 ++-
 .../pypaimon/ray/data_evolution_merge_join.py      |   6 +-
 .../pypaimon/ray/data_evolution_merge_transform.py |   4 +
 paimon-python/pypaimon/ray/merge_condition.py      |   9 +-
 .../pypaimon/table/data_evolution_merge_into.py    | 677 +++++++++++++++++++++
 .../tests/ray_data_evolution_merge_into_test.py    | 140 ++++-
 .../pypaimon/tests/table_merge_into_test.py        | 656 ++++++++++++++++++++
 paimon-python/pypaimon/write/table_update.py       |  57 +-
 20 files changed, 1747 insertions(+), 331 deletions(-)

diff --git a/.github/workflows/check-licensing.yml 
b/.github/workflows/check-licensing.yml
index 78129b8c3d..207cdd13fa 100644
--- a/.github/workflows/check-licensing.yml
+++ b/.github/workflows/check-licensing.yml
@@ -17,6 +17,10 @@ name: Check licensing
 
 on:
   push:
+    paths-ignore:
+      - 'docs/**'
+      - '**/*.md'
+      - 'paimon-python/**'
   pull_request:
     paths-ignore:
       - 'docs/**'
@@ -64,4 +68,4 @@ jobs:
       - name: Check python licensing
         run: |
           chmod +x paimon-python/dev/check-licensing.sh
-          ./paimon-python/dev/check-licensing.sh
\ No newline at end of file
+          ./paimon-python/dev/check-licensing.sh
diff --git a/.github/workflows/e2e-tests-flink-1.x.yml 
b/.github/workflows/e2e-tests-flink-1.x.yml
index a580149869..3d7c25b600 100644
--- a/.github/workflows/e2e-tests-flink-1.x.yml
+++ b/.github/workflows/e2e-tests-flink-1.x.yml
@@ -20,6 +20,12 @@ name: End to End Tests Flink 1.x
 
 on:
   push:
+    paths-ignore:
+      - 'docs/**'
+      - '**/*.md'
+      - 'paimon-python/**'
+      - '.github/workflows/paimon-python-checks.yml'
+      - 'paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java'
   pull_request:
     paths-ignore:
       - 'docs/**'
diff --git a/.github/workflows/e2e-tests-flink-2.x-jdk11.yml 
b/.github/workflows/e2e-tests-flink-2.x-jdk11.yml
index bf74cf1cd6..548916d106 100644
--- a/.github/workflows/e2e-tests-flink-2.x-jdk11.yml
+++ b/.github/workflows/e2e-tests-flink-2.x-jdk11.yml
@@ -20,6 +20,12 @@ name: End to End Tests Flink 2.x on JDK 11
 
 on:
   push:
+    paths-ignore:
+      - 'docs/**'
+      - '**/*.md'
+      - 'paimon-python/**'
+      - '.github/workflows/paimon-python-checks.yml'
+      - 'paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java'
   pull_request:
     paths-ignore:
       - 'docs/**'
@@ -72,4 +78,4 @@ jobs:
             mvn -T 1C -B -ntp test -Pflink2,spark3 -pl paimon-e2e-tests 
-Duser.timezone=$jvm_timezone -P${profile},java11
           fi
         env:
-          MAVEN_OPTS: -Xmx4096m
\ No newline at end of file
+          MAVEN_OPTS: -Xmx4096m
diff --git a/.github/workflows/utitcase-flink-1.x-common.yml 
b/.github/workflows/utitcase-flink-1.x-common.yml
index f22b56014f..59d124804d 100644
--- a/.github/workflows/utitcase-flink-1.x-common.yml
+++ b/.github/workflows/utitcase-flink-1.x-common.yml
@@ -19,6 +19,12 @@ name: UTCase and ITCase Flink 1.x Common
 
 on:
   push:
+    paths-ignore:
+      - 'docs/**'
+      - '**/*.md'
+      - 'paimon-python/**'
+      - '.github/workflows/paimon-python-checks.yml'
+      - 'paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java'
   pull_request:
     paths-ignore:
       - 'docs/**'
@@ -66,4 +72,4 @@ jobs:
           mvn -T 2C -B -ntp test verify -Pflink1,spark3 -pl "${TEST_MODULE}" 
-Duser.timezone=$jvm_timezone
           echo "All modules tested"
         env:
-          MAVEN_OPTS: -Xmx4096m -XX:+UseG1GC -XX:CICompilerCount=2
\ No newline at end of file
+          MAVEN_OPTS: -Xmx4096m -XX:+UseG1GC -XX:CICompilerCount=2
diff --git a/.github/workflows/utitcase-flink-1.x-others.yml 
b/.github/workflows/utitcase-flink-1.x-others.yml
index d4424685c0..d042f1a8ed 100644
--- a/.github/workflows/utitcase-flink-1.x-others.yml
+++ b/.github/workflows/utitcase-flink-1.x-others.yml
@@ -19,6 +19,12 @@ name: UTCase and ITCase Flink 1.x Others
 
 on:
   push:
+    paths-ignore:
+      - 'docs/**'
+      - '**/*.md'
+      - 'paimon-python/**'
+      - '.github/workflows/paimon-python-checks.yml'
+      - 'paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java'
   pull_request:
     paths-ignore:
       - 'docs/**'
@@ -66,4 +72,4 @@ jobs:
           test_modules="${test_modules%,}"
           mvn -T 2C -B -ntp test verify -Pflink1,spark3 -pl "${test_modules}" 
-Duser.timezone=$jvm_timezone
         env:
-          MAVEN_OPTS: -Xmx4096m -XX:+UseG1GC -XX:CICompilerCount=2
\ No newline at end of file
+          MAVEN_OPTS: -Xmx4096m -XX:+UseG1GC -XX:CICompilerCount=2
diff --git a/.github/workflows/utitcase-flink-2.x-jdk11.yml 
b/.github/workflows/utitcase-flink-2.x-jdk11.yml
index ddfcaaf671..b1b6005867 100644
--- a/.github/workflows/utitcase-flink-2.x-jdk11.yml
+++ b/.github/workflows/utitcase-flink-2.x-jdk11.yml
@@ -20,6 +20,12 @@ name: UTCase and ITCase Flink 2.x on JDK 11
 
 on:
   push:
+    paths-ignore:
+      - 'docs/**'
+      - '**/*.md'
+      - 'paimon-python/**'
+      - '.github/workflows/paimon-python-checks.yml'
+      - 'paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java'
   pull_request:
     paths-ignore:
       - 'docs/**'
@@ -67,4 +73,4 @@ jobs:
           test_modules="${test_modules%,}"
           mvn -T 2C -B -ntp test verify -Pflink2,spark3 -pl "${test_modules}" 
-Duser.timezone=$jvm_timezone
         env:
-          MAVEN_OPTS: -Xmx4096m
\ No newline at end of file
+          MAVEN_OPTS: -Xmx4096m
diff --git a/.github/workflows/utitcase-jdk11.yml 
b/.github/workflows/utitcase-jdk11.yml
index 1f04b44d35..a6ae09a52b 100644
--- a/.github/workflows/utitcase-jdk11.yml
+++ b/.github/workflows/utitcase-jdk11.yml
@@ -20,6 +20,12 @@ name: UTCase and ITCase Others on JDK 11
 
 on:
   push:
+    paths-ignore:
+      - 'docs/**'
+      - '**/*.md'
+      - 'paimon-python/**'
+      - '.github/workflows/paimon-python-checks.yml'
+      - 'paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java'
   pull_request:
     paths-ignore:
       - 'docs/**'
@@ -63,4 +69,4 @@ jobs:
           test_modules="${test_modules%,}"
           mvn -T 1C -B -ntp clean install -pl "${test_modules}" 
-Pflink1,spark3 -Pskip-paimon-flink-tests -Duser.timezone=$jvm_timezone
         env:
-          MAVEN_OPTS: -Xmx4096m
\ No newline at end of file
+          MAVEN_OPTS: -Xmx4096m
diff --git a/.github/workflows/utitcase-spark-3.x.yml 
b/.github/workflows/utitcase-spark-3.x.yml
index dd6cf8f83b..0fe7923060 100644
--- a/.github/workflows/utitcase-spark-3.x.yml
+++ b/.github/workflows/utitcase-spark-3.x.yml
@@ -20,6 +20,12 @@ name: UTCase and ITCase Spark 3.x
 
 on:
   push:
+    paths-ignore:
+      - 'docs/**'
+      - '**/*.md'
+      - 'paimon-python/**'
+      - '.github/workflows/paimon-python-checks.yml'
+      - 'paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java'
   pull_request:
     paths-ignore:
       - 'docs/**'
@@ -70,4 +76,4 @@ jobs:
           test_modules="${test_modules%,}"
           mvn -T 2C -B -ntp verify -pl "${test_modules}" 
-Duser.timezone=$jvm_timezone -Pspark3,flink1,scala-${{ matrix.scala_version }}
         env:
-          MAVEN_OPTS: -Xmx4096m
\ No newline at end of file
+          MAVEN_OPTS: -Xmx4096m
diff --git a/.github/workflows/utitcase-spark-4.x.yml 
b/.github/workflows/utitcase-spark-4.x.yml
index 993fa97ba2..72c43c018e 100644
--- a/.github/workflows/utitcase-spark-4.x.yml
+++ b/.github/workflows/utitcase-spark-4.x.yml
@@ -20,6 +20,12 @@ name: UTCase and ITCase Spark 4.x
 
 on:
   push:
+    paths-ignore:
+      - 'docs/**'
+      - '**/*.md'
+      - 'paimon-python/**'
+      - '.github/workflows/paimon-python-checks.yml'
+      - 'paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java'
   pull_request:
     paths-ignore:
       - 'docs/**'
@@ -67,4 +73,4 @@ jobs:
           test_modules="${test_modules%,}"
           mvn -T 2C -B -ntp verify -pl "${test_modules}" 
-Duser.timezone=$jvm_timezone -Pspark4,flink1
         env:
-          MAVEN_OPTS: -Xmx4096m
\ No newline at end of file
+          MAVEN_OPTS: -Xmx4096m
diff --git a/.github/workflows/utitcase.yml b/.github/workflows/utitcase.yml
index 2c1aedc609..e83be074f4 100644
--- a/.github/workflows/utitcase.yml
+++ b/.github/workflows/utitcase.yml
@@ -20,6 +20,12 @@ name: UTCase and ITCase Others
 
 on:
   push:
+    paths-ignore:
+      - 'docs/**'
+      - '**/*.md'
+      - 'paimon-python/**'
+      - '.github/workflows/paimon-python-checks.yml'
+      - 'paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java'
   pull_request:
     paths-ignore:
       - 'docs/**'
@@ -69,4 +75,4 @@ jobs:
           TEST_MODULES="${TEST_MODULES%,}"
           mvn -T 2C -B -ntp clean install -pl "${TEST_MODULES}" 
-Pskip-paimon-flink-tests -Duser.timezone=$jvm_timezone
         env:
-          MAVEN_OPTS: -Xmx4096m -XX:+UseG1GC -XX:CICompilerCount=2
\ No newline at end of file
+          MAVEN_OPTS: -Xmx4096m -XX:+UseG1GC -XX:CICompilerCount=2
diff --git a/docs/docs/pypaimon/data-evolution.md 
b/docs/docs/pypaimon/data-evolution.md
index 1ec5a3a0a2..8eb7c220c7 100644
--- a/docs/docs/pypaimon/data-evolution.md
+++ b/docs/docs/pypaimon/data-evolution.md
@@ -3,9 +3,6 @@ title: "Data Evolution"
 sidebar_position: 5
 ---
 
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-
 <!--
 Licensed to the Apache Software Foundation (ASF) under one
 or more contributor license agreements.  See the NOTICE file
@@ -36,46 +33,6 @@ To use partial updates / data evolution, enable both options 
when creating the t
 - **`row-tracking.enabled`**: `true`
 - **`data-evolution.enabled`**: `true`
 
-## Batch vs Stream
-
-Data evolution supports both batch and stream modes.
-
-<Tabs groupId="pypaimon-data-evolution-mode">
-
-<TabItem value="batch" label="Batch">
-
-- Builder: `table.new_batch_write_builder()`
-- Write: `BatchTableWrite`
-- Update: `BatchTableUpdate`
-- Commit: `BatchTableCommit`
-- `commit_identifier`: not required
-- Lifecycle: one-shot; each instance can commit only once
-- `prepare_commit()`: `write.prepare_commit()`
-- `update_by_arrow_with_row_id()`: `update.update_by_arrow_with_row_id(table)`
-- `update_by_predicate()`: `update.update_by_predicate(predicate, assignments)`
-- `upsert_by_arrow_with_key()`: `update.upsert_by_arrow_with_key(table, keys)`
-- `commit()`: `commit.commit(messages)`
-
-</TabItem>
-
-<TabItem value="stream" label="Stream">
-
-- Builder: `table.new_stream_write_builder()`
-- Write: `StreamTableWrite`
-- Update: `StreamTableUpdate`
-- Commit: `StreamTableCommit`
-- `commit_identifier`: required; use a monotonically increasing integer
-- Lifecycle: reusable; the same instance can commit many rounds
-- `prepare_commit()`: `write.prepare_commit(commit_identifier)`
-- `update_by_arrow_with_row_id()`: `update.update_by_arrow_with_row_id(table, 
commit_identifier)`
-- `update_by_predicate()`: `update.update_by_predicate(predicate, assignments, 
commit_identifier)`
-- `upsert_by_arrow_with_key()`: `update.upsert_by_arrow_with_key(table, keys, 
commit_identifier)`
-- `commit()`: `commit.commit(messages, commit_identifier)`
-
-</TabItem>
-
-</Tabs>
-
 ## Update Columns By Row ID
 
 You can use `update_by_arrow_with_row_id` to update columns in data evolution 
tables.
@@ -87,10 +44,6 @@ to its corresponding `first_row_id`, then group rows with 
the same `first_row_id
 
 - **Update columns only**: include `_ROW_ID` plus the columns you want to 
update (partial schema is OK).
 
-<Tabs groupId="pypaimon-data-evolution-mode">
-
-<TabItem value="batch" label="Batch">
-
 ```python
 import pyarrow as pa
 from pypaimon import CatalogFactory, Schema
@@ -140,64 +93,6 @@ table_commit.close()
 #   'f1': [-1001, 1002]
 ```
 
-</TabItem>
-
-<TabItem value="stream" label="Stream">
-
-```python
-import pyarrow as pa
-from pypaimon import CatalogFactory, Schema
-
-catalog = CatalogFactory.create({'warehouse': '/tmp/warehouse'})
-catalog.create_database('default', False)
-
-simple_pa_schema = pa.schema([
-  ('f0', pa.int8()),
-  ('f1', pa.int16()),
-])
-schema = Schema.from_pyarrow_schema(simple_pa_schema,
-                                    options={'row-tracking.enabled': 'true', 
'data-evolution.enabled': 'true'})
-catalog.create_table('default.test_stream', schema, False)
-table = catalog.get_table('default.test_stream')
-
-# write initial data
-write_builder = table.new_batch_write_builder()
-table_write = write_builder.new_write()
-table_commit = write_builder.new_commit()
-table_write.write_arrow(pa.Table.from_pydict({
-  'f0': [-1, 2],
-  'f1': [-1001, 1002]
-}, schema=simple_pa_schema))
-table_commit.commit(table_write.prepare_commit())
-table_write.close()
-table_commit.close()
-
-# stream update: each round uses a new commit_identifier
-stream_builder = table.new_stream_write_builder()
-table_update = stream_builder.new_update().with_update_type(['f0'])
-table_commit = stream_builder.new_commit()
-
-data1 = pa.Table.from_pydict({
-  '_ROW_ID': [0],
-  'f0': [5],
-}, schema=pa.schema([('_ROW_ID', pa.int64()), ('f0', pa.int8())]))
-cmts1 = table_update.update_by_arrow_with_row_id(data1, commit_identifier=1)
-table_commit.commit(cmts1, commit_identifier=1)
-
-data2 = pa.Table.from_pydict({
-  '_ROW_ID': [1],
-  'f0': [6],
-}, schema=pa.schema([('_ROW_ID', pa.int64()), ('f0', pa.int8())]))
-cmts2 = table_update.update_by_arrow_with_row_id(data2, commit_identifier=2)
-table_commit.commit(cmts2, commit_identifier=2)
-
-table_commit.close()
-```
-
-</TabItem>
-
-</Tabs>
-
 ## Update Columns By Predicate
 
 You can use `update_by_predicate` for SQL-like `UPDATE ... SET ... WHERE ...`
@@ -207,10 +102,6 @@ When global indexes are available, `update_by_predicate` 
discovers matching
 `_ROW_ID` values with `global-index.search-mode=full` on the configured
 point-in-time scan snapshot or, if none is configured, the latest snapshot.
 
-<Tabs groupId="pypaimon-data-evolution-mode">
-
-<TabItem value="batch" label="Batch">
-
 ```python
 import pyarrow as pa
 from pypaimon import CatalogFactory, Schema
@@ -253,31 +144,6 @@ commit.commit(messages)
 commit.close()
 ```
 
-</TabItem>
-
-<TabItem value="stream" label="Stream">
-
-Pass the `commit_identifier` to both `update_by_predicate` and `commit`:
-
-```python
-stream_builder = table.new_stream_write_builder()
-table_update = stream_builder.new_update()
-table_commit = stream_builder.new_commit()
-
-predicate = table_update.new_predicate_builder().equal('id', 2)
-messages = table_update.update_by_predicate(
-    predicate,
-    {'name': 'Bob_v2'},
-    commit_identifier=1,
-)
-table_commit.commit(messages, commit_identifier=1)
-table_commit.close()
-```
-
-</TabItem>
-
-</Tabs>
-
 ## Filter by _ROW_ID
 
 Requires the same [Prerequisites](#prerequisites) (row-tracking and 
data-evolution enabled). On such tables you can filter by `_ROW_ID` to prune 
files at scan time. Supported: `equal('_ROW_ID', id)`, `is_in('_ROW_ID', [id1, 
...])`, `between('_ROW_ID', low, high)`.
@@ -304,10 +170,6 @@ If you want to **upsert** (update-or-insert) rows by one 
or more business key co
   **automatically stripped** from `upsert_keys` during matching (since each 
partition is processed independently),
   so you do **not** need to include them in `upsert_keys`.
 
-<Tabs groupId="pypaimon-data-evolution-mode">
-
-<TabItem value="batch" label="Batch">
-
 **Example: basic upsert**
 
 ```python
@@ -420,13 +282,37 @@ table_commit.close()
 - Duplicate keys in the input data are automatically deduplicated — the **last 
occurrence** is kept.
 - The upsert is atomic per commit — all matched updates and new appends are 
included in the same commit.
 
-</TabItem>
+## Merge Into
+
+Use `merge_into` when your source data should update matched target rows and
+optionally insert rows that do not match, similar to SQL `MERGE INTO`.
+`merge_into` is exposed from `TableUpdate`, so it follows the same
+commit-message lifecycle as other PyPaimon update APIs. The PyPaimon
+implementation runs in a single process and materializes the rows it needs
+locally.
+
+Matched rows are updated by `_ROW_ID` internally. Only the columns touched by
+the matched clauses are rewritten. `merge_into` derives the update columns from
+the `WhenMatched` clauses; `with_update_type` is not needed.
+
+**Requirements**
 
-<TabItem value="stream" label="Stream">
+- The target table must have `data-evolution.enabled = true` and
+  `row-tracking.enabled = true`.
+- `source` must be a `pyarrow.Table`, `pandas.DataFrame`, or another PyPaimon
+  table object.
+- `on` can be a list of same-named key columns, or `{target_col: source_col}`
+  for renamed source keys.
+- If multiple source rows match the same target `_ROW_ID`, `merge_into` raises
+  an error. Deduplicate the source before merging.
 
 ```python
 import pyarrow as pa
 from pypaimon import CatalogFactory, Schema
+from pypaimon.table.data_evolution_merge_into import (
+    WhenMatched,
+    WhenNotMatched,
+)
 
 catalog = CatalogFactory.create({'warehouse': '/tmp/warehouse'})
 catalog.create_database('default', False)
@@ -440,8 +326,8 @@ schema = Schema.from_pyarrow_schema(
     pa_schema,
     options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'},
 )
-catalog.create_table('default.users_stream', schema, False)
-table = catalog.get_table('default.users_stream')
+catalog.create_table('default.users_merge', schema, False)
+table = catalog.get_table('default.users_merge')
 
 # write initial data
 write_builder = table.new_batch_write_builder()
@@ -455,33 +341,79 @@ commit.commit(write.prepare_commit())
 write.close()
 commit.close()
 
-# stream upsert: each round uses a new commit_identifier
-stream_builder = table.new_stream_write_builder()
-table_update = stream_builder.new_update()
-table_commit = stream_builder.new_commit()
-
-upsert_data1 = pa.Table.from_pydict(
-    {'id': [1, 3], 'name': ['Alice_v2', 'Charlie'], 'age': [31, 28]},
+# merge: update id=2, insert id=3
+source = pa.Table.from_pydict(
+    {'id': [2, 3], 'name': ['Bob_v2', 'Charlie'], 'age': [26, 28]},
     schema=pa_schema,
 )
-cmts1 = table_update.upsert_by_arrow_with_key(upsert_data1, 
upsert_keys=['id'], commit_identifier=1)
-table_commit.commit(cmts1, commit_identifier=1)
 
-upsert_data2 = pa.Table.from_pydict(
-    {'id': [2, 4], 'name': ['Bob_v2', 'David'], 'age': [26, 40]},
-    schema=pa_schema,
-)
-cmts2 = table_update.upsert_by_arrow_with_key(upsert_data2, 
upsert_keys=['id'], commit_identifier=2)
-table_commit.commit(cmts2, commit_identifier=2)
+write_builder = table.new_batch_write_builder()
+table_update = write_builder.new_update()
+table_commit = write_builder.new_commit()
 
+messages = table_update.merge_into(
+    source,
+    on=['id'],
+    when_matched=[WhenMatched(update='*')],
+    when_not_matched=[WhenNotMatched(insert='*')],
+)
+table_commit.commit(messages)
 table_commit.close()
 ```
 
-The `with_update_type` and partitioned table patterns shown in Batch Mode also 
work in Stream Mode — just add `commit_identifier` to the 
`upsert_by_arrow_with_key` and `commit` calls.
+`WhenMatched` and `WhenNotMatched` clauses can use `'*'` to copy same-named
+columns from source, or a mapping for explicit assignments:
+
+```python
+from pypaimon.table.data_evolution_merge_into import (
+    WhenMatched,
+    WhenNotMatched,
+    lit,
+    source_col,
+    target_col,
+)
+
+messages = table_update.merge_into(
+    source,
+    on={'id': 'source_id'},
+    when_matched=[
+        WhenMatched(update={
+            'age': source_col('new_age'),
+            'name': target_col('name'),
+        }),
+    ],
+    when_not_matched=[
+        WhenNotMatched(insert={
+            'id': source_col('source_id'),
+            'name': source_col('name'),
+            'age': lit(0),
+        }),
+    ],
+)
+```
+
+Conditions use SQL-style expressions with `s.` (source) and `t.` (target)
+column prefixes. `WhenNotMatched` conditions may only reference source columns
+(`s.*`). Condition evaluation uses DataFusion through the PyPaimon SQL extra.
+Install the extra before using conditions: `pip install pypaimon[sql]`.
 
-</TabItem>
+```python
+messages = table_update.merge_into(
+    source,
+    on=['id'],
+    when_matched=[WhenMatched(update='*', condition='s.age > t.age')],
+    when_not_matched=[WhenNotMatched(insert='*', condition='s.age > 18')],
+)
+```
+
+**Notes**
 
-</Tabs>
+- Multiple clauses are evaluated in order; the first matching condition wins.
+- Matched clauses cannot update partition key columns, because cross-partition
+  row movement is not implemented.
+- Blob columns can be updated and inserted by `merge_into`. With `update="*"`
+  or `insert="*"`, the source must include the corresponding blob columns.
+  If an insert mapping omits a blob column, that column is written as `NULL`.
 
 ## Update Columns By Shards
 
@@ -495,10 +427,6 @@ If you want to **compute a derived column** (or **update 
an existing column base
 
 This is useful for backfilling a newly added column, or recomputing a column 
from other columns.
 
-<Tabs groupId="pypaimon-data-evolution-mode">
-
-<TabItem value="batch" label="Batch">
-
 **Example: compute `d = c + b - a`**
 
 ```python
@@ -580,70 +508,38 @@ commit.commit(commit_messages)
 commit.close()
 ```
 
-</TabItem>
-
-<TabItem value="stream" label="Stream">
-
-```python
-import pyarrow as pa
-from pypaimon import CatalogFactory, Schema
-
-catalog = CatalogFactory.create({'warehouse': '/tmp/warehouse'})
-catalog.create_database('default', False)
-
-table_schema = pa.schema([
-    ('a', pa.int32()),
-    ('b', pa.int32()),
-    ('c', pa.int32()),
-    ('d', pa.int32()),
-])
-
-schema = Schema.from_pyarrow_schema(
-    table_schema,
-    options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'},
-)
-catalog.create_table('default.t_stream', schema, False)
-table = catalog.get_table('default.t_stream')
-
-# write initial data (a, b, c only)
-write_builder = table.new_batch_write_builder()
-write = write_builder.new_write().with_write_type(['a', 'b', 'c'])
-commit = write_builder.new_commit()
-write.write_arrow(pa.Table.from_pydict({'a': [1, 2], 'b': [10, 20], 'c': [100, 
200]}))
-commit.commit(write.prepare_commit())
-write.close()
-commit.close()
-
-# stream shard update: each round uses a new commit_identifier
-stream_builder = table.new_stream_write_builder()
-table_update = stream_builder.new_update()
-table_update.with_read_projection(['a', 'b', 'c'])
-table_update.with_update_type(['d'])
-table_commit = stream_builder.new_commit()
-
-upd = table_update.new_shard_updator(0, 1)
-reader = upd.arrow_reader()
-
-for batch in iter(reader.read_next_batch, None):
-    a = batch.column('a').to_pylist()
-    b = batch.column('b').to_pylist()
-    c = batch.column('c').to_pylist()
-    d = [ci + bi - ai for ai, bi, ci in zip(a, b, c)]
-
-    upd.update_by_arrow_batch(
-        pa.RecordBatch.from_pydict({'d': d}, schema=pa.schema([('d', 
pa.int32())]))
-    )
-
-commit_messages = upd.prepare_commit()
-table_commit.commit(commit_messages, commit_identifier=1)
-```
-
-</TabItem>
-
-</Tabs>
-
 **Notes**
 
 - **Row order matters**: the batches you write must have the **same number of 
rows** as the batches you read, in the
   same order for that shard.
 - **Parallelism**: run multiple shards by calling 
`new_shard_updator(shard_idx, num_shards)` for each shard.
+
+## Stream Mode
+
+Data evolution also supports stream mode. The operation semantics are the same
+as the batch APIs above; the main differences are the builder lifecycle and the
+required `commit_identifier`.
+
+- Use `table.new_stream_write_builder()` instead of
+  `table.new_batch_write_builder()`.
+- `StreamTableWrite`, `StreamTableUpdate`, and `StreamTableCommit` are reusable
+  across multiple rounds.
+- Each round must use a monotonically increasing `commit_identifier`.
+- Pass the same `commit_identifier` to the write prepare step or update method,
+  and to the corresponding commit call for that round.
+
+The API mapping is:
+
+| Batch API | Stream API |
+| --- | --- |
+| `write.prepare_commit()` | `write.prepare_commit(commit_identifier)` |
+| `update.update_by_arrow_with_row_id(table)` | 
`update.update_by_arrow_with_row_id(table, commit_identifier)` |
+| `update.update_by_predicate(predicate, assignments)` | 
`update.update_by_predicate(predicate, assignments, commit_identifier)` |
+| `update.upsert_by_arrow_with_key(table, keys)` | 
`update.upsert_by_arrow_with_key(table, keys, commit_identifier)` |
+| `update.merge_into(source, on=..., when_matched=..., when_not_matched=...)` 
| `update.merge_into(source, on=..., when_matched=..., when_not_matched=..., 
commit_identifier=...)` |
+| `commit.commit(messages)` | `commit.commit(messages, commit_identifier)` |
+
+For shard updates, create the updater from `StreamTableUpdate` in the same way
+as batch mode. `new_shard_updator(...)`, `arrow_reader()`,
+`update_by_arrow_batch(...)`, and `prepare_commit()` stay the same; pass
+`commit_identifier` when committing the returned messages.
diff --git a/docs/docs/pypaimon/ray-data.md b/docs/docs/pypaimon/ray-data.md
index 08a88a60d5..8297c00e4b 100644
--- a/docs/docs/pypaimon/ray-data.md
+++ b/docs/docs/pypaimon/ray-data.md
@@ -375,9 +375,11 @@ merge_into(
 
 Conditions use SQL-style expressions with `s.` (source) and `t.` (target)
 column prefixes. `WhenNotMatched` conditions may only reference source
-columns (`s.*`). Requires the `datafusion` package: `pip install 
pypaimon[sql]`.
+columns (`s.*`). Condition evaluation uses DataFusion through the PyPaimon SQL
+extra. Install the extra before using conditions: `pip install pypaimon[sql]`.
 
-- `update` / `insert`: `"*"` updates/inserts all non-blob columns from source.
+- `update` / `insert`: `"*"` updates/inserts all columns from source,
+  including blob columns.
   A mapping selects specific columns:
   ```python
   from pypaimon.ray import source_col, target_col, lit
@@ -420,6 +422,6 @@ For an end-to-end feature update workflow on Blob tables, 
see
   table is partitioned, `merge_into` raises an error when `when_matched` is
   specified, because cross-partition row movement is not implemented.
   Not-matched inserts into partitioned tables work normally.
-- Blob columns are not written by `merge_into`: update leaves the existing
-  `.blob` files untouched, and insert fills blob columns with `NULL`. The
-  source data does not need to (and should not) carry blob columns.
+- Blob columns can be updated and inserted by `merge_into`. With `update="*"`
+  or `insert="*"`, the source must include the corresponding blob columns.
+  If an insert mapping omits a blob column, that column is written as `NULL`.
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_into.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
index ab20a56dc7..338dc6ba2f 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
@@ -23,6 +23,7 @@ from typing import Any, Dict, List, Mapping, Optional, 
Sequence, Tuple
 
 import pyarrow as pa
 
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
 from pypaimon.ray.data_evolution_merge_join import (
     build_matched_update_ds,
     build_not_matched_insert_ds,
@@ -119,14 +120,8 @@ def _prepare(target, source, catalog_options, 
when_matched, when_not_matched, on
             f"merge_into requires 'row-tracking.enabled' = 'true' on 
'{target}'."
         )
 
-    blob_cols = _blob_col_names(table)
     full_target_field_names = list(table.field_names)
-    # SET specs only cover non-blob columns: update can't rewrite blob files
-    # (data evolution puts them in dedicated .blob files), and insert leaves
-    # blob columns null since the source can't carry them through SET="*".
-    settable_field_names = [
-        c for c in full_target_field_names if c not in blob_cols
-    ]
+    settable_field_names = list(full_target_field_names)
     on_map = dict(zip(target_on_cols, source_on_cols))
     matched_specs = [
         _NormalizedClause(
@@ -153,9 +148,9 @@ def _prepare(target, source, catalog_options, when_matched, 
when_not_matched, on
     )
     if has_condition:
         from pypaimon.ray.merge_condition import (
-            _require_datafusion, extract_target_columns,
+            _load_datafusion, extract_target_columns,
         )
-        _require_datafusion()
+        _load_datafusion()
         for c in when_not_matched:
             if c.condition is not None:
                 t_refs = extract_target_columns(c.condition)
@@ -164,14 +159,6 @@ def _prepare(target, source, catalog_options, 
when_matched, when_not_matched, on
                         f"WhenNotMatched condition must not reference "
                         f"target columns (t.*), but found: {sorted(t_refs)}"
                     )
-        for c in list(when_matched) + list(when_not_matched):
-            if c.condition is not None:
-                blob_refs = extract_target_columns(c.condition) & blob_cols
-                if blob_refs:
-                    raise ValueError(
-                        f"condition must not reference blob columns, "
-                        f"but found: {sorted(blob_refs)}"
-                    )
     not_matched_specs = []
     for c in when_not_matched:
         spec = _normalize_set_spec(
@@ -238,9 +225,6 @@ def _prepare(target, source, catalog_options, when_matched, 
when_not_matched, on
     full_pa_schema = PyarrowFieldParser.from_paimon_schema(
         table.table_schema.fields
     )
-    # update_pa_schema strips blob (only non-blob cols are written by the
-    # update path); insert_pa_schema is the full table schema so the writer
-    # gets every column (blob columns end up null).
     update_pa_schema = pa.schema(
         [full_pa_schema.field(c) for c in settable_field_names]
     )
@@ -282,43 +266,43 @@ def _build_datasets(
     if ctx.is_self_merge:
         if matched_specs and base_snapshot is not None:
             update_cols_union = _union_update_cols(matched_specs)
-            update_ds = build_self_merge_update_ds(
+            if update_cols_union:
+                update_ds = build_self_merge_update_ds(
+                    target_identifier=target,
+                    clauses=matched_specs,
+                    target_field_names=ctx.full_target_field_names,
+                    target_pa_schema=ctx.update_pa_schema,
+                    update_cols=update_cols_union,
+                    catalog_options=ctx.catalog_options,
+                    resolve_target_projection=_resolve_target_projection,
+                    snapshot_id=base_snapshot_id,
+                    ray_remote_args=ray_remote_args,
+                )
+        return update_ds, insert_ds, update_cols_union
+
+    # Mirror Spark: matched/not-matched run as two independent joins
+    # (inner / left_anti). One unified left_outer join would force
+    # joined.materialize() to feed both branches, which can OOM on large 
merges.
+    if matched_specs and base_snapshot is not None:
+        update_cols_union = _union_update_cols(matched_specs)
+        if update_cols_union:
+            update_ds = build_matched_update_ds(
                 target_identifier=target,
+                source_ds=source_ds,
+                target_on=ctx.target_on_cols,
+                source_on=ctx.source_on_cols,
                 clauses=matched_specs,
-                target_field_names=ctx.full_target_field_names,
+                target_field_names=ctx.settable_field_names,
                 target_pa_schema=ctx.update_pa_schema,
                 update_cols=update_cols_union,
                 catalog_options=ctx.catalog_options,
+                num_partitions=num_partitions,
                 resolve_target_projection=_resolve_target_projection,
                 snapshot_id=base_snapshot_id,
                 ray_remote_args=ray_remote_args,
             )
-        return update_ds, insert_ds, update_cols_union
-
-    # Mirror Spark: matched/not-matched run as two independent joins
-    # (inner / left_anti). One unified left_outer join would force
-    # joined.materialize() to feed both branches, which can OOM on large 
merges.
-    if matched_specs and base_snapshot is not None:
-        update_cols_union = _union_update_cols(matched_specs)
-        update_ds = build_matched_update_ds(
-            target_identifier=target,
-            source_ds=source_ds,
-            target_on=ctx.target_on_cols,
-            source_on=ctx.source_on_cols,
-            clauses=matched_specs,
-            target_field_names=ctx.settable_field_names,
-            target_pa_schema=ctx.update_pa_schema,
-            update_cols=update_cols_union,
-            catalog_options=ctx.catalog_options,
-            num_partitions=num_partitions,
-            resolve_target_projection=_resolve_target_projection,
-            snapshot_id=base_snapshot_id,
-            ray_remote_args=ray_remote_args,
-        )
 
     if not_matched_specs:
-        # Insert writes the full target schema; SET spec only covers
-        # settable cols, so blob columns fall through to null.
         insert_ds = build_not_matched_insert_ds(
             target_identifier=target,
             source_ds=source_ds,
@@ -369,7 +353,10 @@ def _execute_and_commit(
         except Exception as e:
             _reraise_inner(e)
         num_inserted = sum(
-            f.row_count for m in insert_msgs for f in m.new_files
+            f.row_count
+            for m in insert_msgs
+            for f in m.new_files
+            if not DataFileMeta.is_blob_file(f.file_name)
         )
         all_msgs.extend(insert_msgs)
     if all_msgs:
@@ -421,14 +408,6 @@ def _require_ray_join() -> None:
         )
 
 
-def _blob_col_names(table) -> set:
-    return {
-        f.name
-        for f in table.table_schema.fields
-        if getattr(f.type, "type", None) == "BLOB"
-    }
-
-
 def _reraise_inner(err: BaseException) -> None:
     """Unwrap Ray's RayTaskError so callers see the worker-side exception."""
     inner = err
@@ -533,6 +512,8 @@ def _normalize_set_spec(
                     f"SET spec references unknown target column "
                     f"'{val.column}'"
                 )
+            if val.column == key:
+                continue
             result[key] = val
         elif isinstance(val, LiteralValue):
             result[key] = val
@@ -549,6 +530,8 @@ def _normalize_set_spec(
                 raise ValueError(
                     f"SET spec references unknown target column '{ref}'"
                 )
+            if ref == key:
+                continue
             result[key] = TargetColumnRef(ref)
         else:
             result[key] = LiteralValue(val)
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 4ad91e7da1..f6cdd1e93c 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -24,6 +24,7 @@ from pypaimon.ray.data_evolution_merge_transform import (
     SourceColumnRef,
     _NormalizedClause,
     build_update_schema,
+    cast_to_schema,
     vectorized_insert_transform,
     vectorized_matched_transform,
 )
@@ -398,7 +399,6 @@ def build_not_matched_insert_ds(
     ray_remote_args: Optional[Dict[str, Any]] = None,
 ):
     from pypaimon.ray.ray_paimon import read_paimon
-    from pypaimon.ray.shuffle import _coerce_large_string_types
 
     captured_field_names = list(target_field_names)
     out_schema = target_pa_schema
@@ -465,8 +465,8 @@ def build_not_matched_insert_ds(
                 ))
                 remaining = remaining.slice(0, 0)
         if not parts:
-            return _coerce_large_string_types(out_schema.empty_table())
-        return _coerce_large_string_types(pa.concat_tables(parts))
+            return out_schema.empty_table()
+        return cast_to_schema(pa.concat_tables(parts), out_schema)
 
     return unmatched.map_batches(
         _transform, **_map_kwargs(ray_remote_args)
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_transform.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_transform.py
index 003977f3e7..c57be3e041 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_transform.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_transform.py
@@ -114,6 +114,10 @@ def vectorized_insert_transform(
     return pa.Table.from_arrays(arrays, schema=target_pa_schema)
 
 
+def cast_to_schema(batch: pa.Table, schema: pa.Schema) -> pa.Table:
+    return batch if batch.schema == schema else batch.cast(schema)
+
+
 def build_update_schema(
     target_pa_schema: pa.Schema,
     update_cols: Sequence[str],
diff --git a/paimon-python/pypaimon/ray/merge_condition.py 
b/paimon-python/pypaimon/ray/merge_condition.py
index 5497406c5c..12710898d7 100644
--- a/paimon-python/pypaimon/ray/merge_condition.py
+++ b/paimon-python/pypaimon/ray/merge_condition.py
@@ -25,14 +25,15 @@ import pyarrow as pa
 _COL_REF_PATTERN = re.compile(r'\b([st])\.(\w+)\b')
 
 
-def _require_datafusion():
+def _load_datafusion():
     try:
         import datafusion
         return datafusion
     except ImportError:
         raise ImportError(
-            "merge_into condition expressions require the 'datafusion' "
-            "package. Install it with: pip install pypaimon[sql]"
+            "merge_into condition expressions require the PyPaimon SQL "
+            "extra, which provides DataFusion support. Install it with: "
+            "pip install pypaimon[sql]"
         )
 
 
@@ -73,7 +74,7 @@ def filter_batch(
 ) -> pa.Table:
     if batch.num_rows == 0:
         return batch
-    datafusion = _require_datafusion()
+    datafusion = _load_datafusion()
     rewritten = condition if _pre_rewritten else rewrite_condition(condition)
     ctx = datafusion.SessionContext()
     ctx.register_record_batches("_batch", [batch.to_batches()])
diff --git a/paimon-python/pypaimon/table/data_evolution_merge_into.py 
b/paimon-python/pypaimon/table/data_evolution_merge_into.py
new file mode 100644
index 0000000000..21eaee808b
--- /dev/null
+++ b/paimon-python/pypaimon/table/data_evolution_merge_into.py
@@ -0,0 +1,677 @@
+################################################################################
+#  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.
+################################################################################
+
+"""Single-process MERGE INTO for Paimon data-evolution tables."""
+
+from dataclasses import dataclass
+from typing import Any, List, Optional, Sequence
+
+import pyarrow as pa
+import pyarrow.compute as pc
+
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.ray.data_evolution_merge_into import (
+    _normalize_on,
+    _normalize_set_spec,
+    _resolve_target_projection,
+    _union_update_cols,
+    _validate_source_has_target_cols,
+)
+from pypaimon.ray.data_evolution_merge_join import _build_matched_transform
+from pypaimon.ray.data_evolution_merge_transform import (
+    OnSpec,
+    SourceColumnRef,
+    WhenMatched,
+    WhenNotMatched,
+    _NormalizedClause,
+    build_update_schema,
+    cast_to_schema,
+    lit,
+    source_col,
+    target_col,
+    vectorized_insert_transform,
+)
+from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
+from pypaimon.table.special_fields import SpecialFields
+from pypaimon.write.commit_message import CommitMessage
+from pypaimon.write.table_write import BatchTableWrite, StreamTableWrite
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+__all__ = [
+    "merge_into",
+    "WhenMatched",
+    "WhenNotMatched",
+    "source_col",
+    "target_col",
+    "lit",
+]
+
+
+@dataclass(frozen=True)
+class _PrepareCtx:
+    target_on_cols: List[str]
+    source_on_cols: List[str]
+    settable_field_names: List[str]
+    full_target_field_names: List[str]
+    update_pa_schema: pa.Schema
+    full_pa_schema: pa.Schema
+    is_self_merge: bool = False
+
+
+def merge_into(
+    target_table,
+    source: Any,
+    *,
+    on: OnSpec,
+    when_matched: Sequence[WhenMatched] = (),
+    when_not_matched: Sequence[WhenNotMatched] = (),
+    commit_user: str,
+    commit_identifier: int = BATCH_COMMIT_IDENTIFIER,
+) -> List[CommitMessage]:
+    """Prepare MERGE INTO commit messages in the current Python process.
+
+    The returned messages must be committed by the caller's matching
+    ``TableCommit``.
+    """
+    base_snapshot = target_table.snapshot_manager().get_latest_snapshot()
+    source_table, matched_specs, not_matched_specs, ctx = _prepare(
+        target_table,
+        source,
+        list(when_matched),
+        list(when_not_matched),
+        on,
+    )
+
+    update_table, insert_table, update_cols_union = _build_tables(
+        target_table,
+        source_table,
+        matched_specs,
+        not_matched_specs,
+        ctx,
+        base_snapshot,
+    )
+
+    return _prepare_commit_messages(
+        target_table,
+        update_table,
+        insert_table,
+        update_cols_union,
+        base_snapshot,
+        commit_user,
+        commit_identifier,
+    )
+
+
+def _prepare(
+    target_table,
+    source,
+    when_matched,
+    when_not_matched,
+    on,
+):
+    if not when_matched and not when_not_matched:
+        raise ValueError(
+            "At least one of when_matched or when_not_matched must be 
non-empty."
+        )
+    for label, clauses in [("when_matched", when_matched),
+                           ("when_not_matched", when_not_matched)]:
+        for i, clause in enumerate(clauses[:-1]):
+            if clause.condition is None:
+                raise ValueError(
+                    "Only the last {} clause may omit its condition. "
+                    "Clause at index {} has no condition, making subsequent "
+                    "clauses unreachable.".format(label, i)
+                )
+
+    target_on_cols, source_on_cols = _normalize_on(on)
+    if not target_table.options.data_evolution_enabled():
+        raise ValueError(
+            "merge_into requires 'data-evolution.enabled' = 'true' on target 
table."
+        )
+    if not target_table.options.row_tracking_enabled():
+        raise ValueError(
+            "merge_into requires 'row-tracking.enabled' = 'true' on target 
table."
+        )
+
+    full_target_field_names = list(target_table.field_names)
+    settable_field_names = list(full_target_field_names)
+    on_map = dict(zip(target_on_cols, source_on_cols))
+
+    matched_specs = [
+        _NormalizedClause(
+            spec=_normalize_set_spec(
+                c.update,
+                settable_field_names,
+                on_map,
+            ),
+            condition=c.condition,
+        )
+        for c in when_matched
+    ]
+    if matched_specs and target_table.partition_keys:
+        partition_set = set(target_table.partition_keys)
+        for clause in matched_specs:
+            modified_partition_cols = partition_set & set(clause.spec.keys())
+            if modified_partition_cols:
+                raise ValueError(
+                    "merge_into does not support updating partition columns "
+                    "{}; cross-partition row movement is not implemented."
+                    .format(sorted(modified_partition_cols))
+                )
+
+    has_condition = any(
+        c.condition is not None
+        for c in list(when_matched) + list(when_not_matched)
+    )
+    if has_condition:
+        from pypaimon.ray.merge_condition import (
+            _load_datafusion,
+            extract_target_columns,
+        )
+
+        _load_datafusion()
+        for c in when_not_matched:
+            if c.condition is not None:
+                t_refs = extract_target_columns(c.condition)
+                if t_refs:
+                    raise ValueError(
+                        "WhenNotMatched condition must not reference "
+                        "target columns (t.*), but found: {}".format(
+                            sorted(t_refs)
+                        )
+                    )
+
+    not_matched_specs = []
+    for c in when_not_matched:
+        spec = _normalize_set_spec(
+            c.insert,
+            settable_field_names,
+            on_map,
+            allow_target_refs=False,
+        )
+        for tk, sk in on_map.items():
+            if tk in settable_field_names and tk not in spec:
+                spec[tk] = SourceColumnRef(sk)
+        not_matched_specs.append(
+            _NormalizedClause(spec=spec, condition=c.condition)
+        )
+
+    is_self_merge = _is_self_merge(
+        target_table, source, target_on_cols, source_on_cols
+    )
+    if is_self_merge and not_matched_specs:
+        raise ValueError(
+            "Self-merge (source == target with ON _ROW_ID) does not "
+            "support WHEN NOT MATCHED clauses."
+        )
+
+    if is_self_merge:
+        source_table = None
+        source_col_names = set(full_target_field_names) | set(source_on_cols)
+    else:
+        source_table = _normalize_source(source)
+        _validate_source_on_cols(source_table, source_on_cols)
+        source_col_names = set(source_table.schema.names)
+
+    _validate_source_has_target_cols(
+        source_col_names, matched_specs + not_matched_specs
+    )
+
+    if has_condition:
+        from pypaimon.ray.merge_condition import extract_columns
+
+        target_names = set(full_target_field_names)
+        if is_self_merge:
+            target_names |= set(target_on_cols)
+        for c in list(when_matched) + list(when_not_matched):
+            if c.condition is not None:
+                for ref in extract_columns(c.condition):
+                    prefix, col = ref.split(".", 1)
+                    if prefix == "s" and col not in source_col_names:
+                        raise ValueError(
+                            "condition references unknown source column '{}'"
+                            .format(col)
+                        )
+                    if prefix == "t" and col not in target_names:
+                        raise ValueError(
+                            "condition references unknown target column '{}'"
+                            .format(col)
+                        )
+
+    from pypaimon.schema.data_types import PyarrowFieldParser
+
+    full_pa_schema = PyarrowFieldParser.from_paimon_schema(
+        target_table.table_schema.fields
+    )
+    update_pa_schema = pa.schema(
+        [full_pa_schema.field(c) for c in settable_field_names]
+    )
+    ctx = _PrepareCtx(
+        target_on_cols=target_on_cols,
+        source_on_cols=source_on_cols,
+        settable_field_names=settable_field_names,
+        full_target_field_names=full_target_field_names,
+        update_pa_schema=update_pa_schema,
+        full_pa_schema=full_pa_schema,
+        is_self_merge=is_self_merge,
+    )
+    return source_table, matched_specs, not_matched_specs, ctx
+
+
+def _build_tables(
+    target_table,
+    source_table: Optional[pa.Table],
+    matched_specs: List[_NormalizedClause],
+    not_matched_specs: List[_NormalizedClause],
+    ctx: _PrepareCtx,
+    base_snapshot,
+):
+    base_snapshot_id = base_snapshot.id if base_snapshot is not None else None
+    update_table = None
+    insert_table = None
+    update_cols_union: List[str] = []
+
+    if ctx.is_self_merge:
+        if matched_specs and base_snapshot is not None:
+            update_cols_union = _union_update_cols(matched_specs)
+            if update_cols_union:
+                update_table = _build_self_merge_update_table(
+                    target_table,
+                    matched_specs,
+                    ctx,
+                    update_cols_union,
+                    base_snapshot_id,
+                )
+        return update_table, insert_table, update_cols_union
+
+    if matched_specs and base_snapshot is not None:
+        update_cols_union = _union_update_cols(matched_specs)
+        if update_cols_union:
+            update_table = _build_matched_update_table(
+                target_table,
+                source_table,
+                matched_specs,
+                ctx,
+                update_cols_union,
+                base_snapshot_id,
+            )
+
+    if not_matched_specs:
+        insert_table = _build_not_matched_insert_table(
+            target_table,
+            source_table,
+            not_matched_specs,
+            ctx,
+            base_snapshot_id,
+            target_empty=base_snapshot is None,
+        )
+
+    return update_table, insert_table, update_cols_union
+
+
+def _build_self_merge_update_table(
+    target_table,
+    clauses: List[_NormalizedClause],
+    ctx: _PrepareCtx,
+    update_cols: Sequence[str],
+    snapshot_id: Optional[int],
+) -> pa.Table:
+    row_id_name = SpecialFields.ROW_ID.name
+    needed_cols = set(
+        _resolve_target_projection(
+            clauses,
+            [row_id_name],
+            update_cols,
+            ctx.full_target_field_names,
+        )
+    )
+    for clause in clauses:
+        for value in clause.spec.values():
+            if isinstance(value, SourceColumnRef):
+                needed_cols.add(value.column)
+    target_set = set(ctx.full_target_field_names)
+    for clause in clauses:
+        if clause.condition is not None:
+            from pypaimon.ray.merge_condition import extract_columns
+
+            for ref in extract_columns(clause.condition):
+                prefix, col = ref.split(".", 1)
+                if prefix == "s" and col in target_set:
+                    needed_cols.add(col)
+    projection = [row_id_name] + [
+        c for c in ctx.full_target_field_names if c in needed_cols
+    ]
+
+    target = _read_table(target_table, projection=projection, 
snapshot_id=snapshot_id)
+    update_schema = build_update_schema(
+        ctx.update_pa_schema, update_cols, row_id_name
+    )
+    if target.num_rows == 0:
+        return update_schema.empty_table()
+
+    orig_names = list(target.schema.names)
+    target_renamed = _rename_with_prefix(target, "t.")
+    aliased = _add_self_merge_source_aliases(
+        target_renamed, orig_names, row_id_name
+    )
+    transform = _build_matched_transform(
+        clauses,
+        on_map={row_id_name: row_id_name},
+        on_pairs=[(row_id_name, row_id_name)],
+        update_cols=list(update_cols),
+        row_id_name=row_id_name,
+        update_schema=update_schema,
+    )
+    return transform(aliased)
+
+
+def _build_matched_update_table(
+    target_table,
+    source_table: pa.Table,
+    clauses: List[_NormalizedClause],
+    ctx: _PrepareCtx,
+    update_cols: Sequence[str],
+    snapshot_id: Optional[int],
+) -> pa.Table:
+    row_id_name = SpecialFields.ROW_ID.name
+    needed_cols = _resolve_target_projection(
+        clauses,
+        ctx.target_on_cols,
+        update_cols,
+        ctx.settable_field_names,
+    )
+    projection = [row_id_name] + [c for c in needed_cols if c != row_id_name]
+    target = _read_table(target_table, projection=projection, 
snapshot_id=snapshot_id)
+    update_schema = build_update_schema(
+        ctx.update_pa_schema, update_cols, row_id_name
+    )
+    if target.num_rows == 0 or source_table.num_rows == 0:
+        return update_schema.empty_table()
+
+    target_renamed = _rename_with_prefix(target, "t.")
+    source_renamed = _rename_with_prefix(source_table, "s.")
+    joined = target_renamed.join(
+        source_renamed,
+        keys=["t.{}".format(c) for c in ctx.target_on_cols],
+        right_keys=["s.{}".format(c) for c in ctx.source_on_cols],
+        join_type="inner",
+    )
+    transform = _build_matched_transform(
+        clauses,
+        on_map=dict(zip(ctx.source_on_cols, ctx.target_on_cols)),
+        on_pairs=list(zip(ctx.source_on_cols, ctx.target_on_cols)),
+        update_cols=list(update_cols),
+        row_id_name=row_id_name,
+        update_schema=update_schema,
+    )
+    return transform(joined)
+
+
+def _build_not_matched_insert_table(
+    target_table,
+    source_table: pa.Table,
+    clauses: List[_NormalizedClause],
+    ctx: _PrepareCtx,
+    snapshot_id: Optional[int],
+    target_empty: bool = False,
+) -> pa.Table:
+    source_renamed = _rename_with_prefix(source_table, "s.")
+    if source_renamed.num_rows == 0:
+        return ctx.full_pa_schema.empty_table()
+
+    if target_empty:
+        unmatched = source_renamed
+    else:
+        target = _read_table(
+            target_table,
+            projection=list(ctx.target_on_cols),
+            snapshot_id=snapshot_id,
+        )
+        if target.num_rows == 0:
+            unmatched = source_renamed
+        else:
+            target_renamed = _rename_with_prefix(target, "t.")
+            unmatched = source_renamed.join(
+                target_renamed,
+                keys=["s.{}".format(c) for c in ctx.source_on_cols],
+                right_keys=["t.{}".format(c) for c in ctx.target_on_cols],
+                join_type="left anti",
+            )
+
+    transform = _build_insert_transform(
+        clauses, ctx.full_target_field_names, ctx.full_pa_schema
+    )
+    return transform(unmatched)
+
+
+def _prepare_commit_messages(
+    table,
+    update_table: Optional[pa.Table],
+    insert_table: Optional[pa.Table],
+    update_cols_union: Sequence[str],
+    base_snapshot,
+    commit_user: str,
+    commit_identifier: int,
+) -> List[CommitMessage]:
+    all_msgs: list = []
+    if update_table is not None and update_table.num_rows > 0:
+        _validate_unique_row_ids(update_table)
+        update_snapshot_table = _copy_at_snapshot(
+            table, base_snapshot.id if base_snapshot is not None else None
+        )
+        updater = TableUpdateByRowId(
+            update_snapshot_table,
+            commit_user,
+            commit_identifier,
+        )
+        update_msgs = updater.update_columns(
+            update_table, list(update_cols_union)
+        )
+        all_msgs.extend(update_msgs)
+
+    if insert_table is not None and insert_table.num_rows > 0:
+        writer = _new_table_write(table, commit_user, commit_identifier)
+        try:
+            writer.write_arrow(insert_table)
+            if commit_identifier == BATCH_COMMIT_IDENTIFIER:
+                insert_msgs = writer.prepare_commit()
+            else:
+                insert_msgs = writer.prepare_commit(commit_identifier)
+        finally:
+            writer.close()
+        all_msgs.extend(insert_msgs)
+
+    return all_msgs
+
+
+def _new_table_write(table, commit_user: str, commit_identifier: int):
+    if commit_identifier == BATCH_COMMIT_IDENTIFIER:
+        return BatchTableWrite(table, commit_user)
+    return StreamTableWrite(table, commit_user)
+
+
+def _build_insert_transform(
+    clauses: List[_NormalizedClause],
+    target_field_names: Sequence[str],
+    target_pa_schema: pa.Schema,
+):
+    prepared_clauses = []
+    for clause in clauses:
+        rewritten = None
+        if clause.condition is not None:
+            from pypaimon.ray.merge_condition import rewrite_condition
+
+            rewritten = rewrite_condition(clause.condition)
+        prepared_clauses.append((clause.spec, rewritten))
+
+    _filter_batch = None
+    if any(r is not None for _, r in prepared_clauses):
+        from pypaimon.ray.merge_condition import filter_batch as _filter_batch
+
+    def _transform(batch: pa.Table) -> pa.Table:
+        remaining = batch
+        parts = []
+        for spec, rewritten in prepared_clauses:
+            if remaining.num_rows == 0:
+                break
+            if rewritten is not None:
+                matched = _filter_batch(
+                    remaining, rewritten, _pre_rewritten=True
+                )
+                if matched.num_rows > 0:
+                    parts.append(
+                        vectorized_insert_transform(
+                            matched,
+                            spec,
+                            target_field_names,
+                            target_pa_schema,
+                        )
+                    )
+                if matched.num_rows < remaining.num_rows:
+                    not_cond = "COALESCE(NOT ({}), TRUE)".format(rewritten)
+                    remaining = _filter_batch(
+                        remaining, not_cond, _pre_rewritten=True
+                    )
+                else:
+                    remaining = remaining.slice(0, 0)
+            else:
+                parts.append(
+                    vectorized_insert_transform(
+                        remaining,
+                        spec,
+                        target_field_names,
+                        target_pa_schema,
+                    )
+                )
+                remaining = remaining.slice(0, 0)
+        if not parts:
+            return target_pa_schema.empty_table()
+        return cast_to_schema(pa.concat_tables(parts), target_pa_schema)
+
+    return _transform
+
+
+def _normalize_source(source: Any) -> pa.Table:
+    if isinstance(source, pa.Table):
+        return source
+    if _is_table_like(source):
+        snapshot = source.snapshot_manager().get_latest_snapshot()
+        snapshot_id = snapshot.id if snapshot is not None else None
+        return _read_table(source, snapshot_id=snapshot_id)
+    try:
+        import pandas as pd
+    except ImportError:
+        pd = None
+    if pd is not None and isinstance(source, pd.DataFrame):
+        return pa.Table.from_pandas(source, preserve_index=False)
+    raise TypeError(
+        "source must be a pyarrow.Table, a pandas.DataFrame, or a "
+        "Paimon table; got {}.".format(type(source).__name__)
+    )
+
+
+def _read_table(table, projection: Optional[List[str]] = None,
+                snapshot_id: Optional[int] = None) -> pa.Table:
+    read_table = _copy_at_snapshot(table, snapshot_id)
+    read_builder = read_table.new_read_builder()
+    if projection is not None:
+        read_builder.with_projection(projection)
+    scan = read_builder.new_scan()
+    return read_builder.new_read().to_arrow(scan.plan().splits())
+
+
+def _copy_at_snapshot(table, snapshot_id: Optional[int]):
+    if snapshot_id is None:
+        return table
+    return table.copy({CoreOptions.SCAN_SNAPSHOT_ID.key(): str(snapshot_id)})
+
+
+def _validate_source_on_cols(source_table: pa.Table, on: Sequence[str]) -> 
None:
+    names = set(source_table.schema.names)
+    missing = [c for c in on if c not in names]
+    if missing:
+        raise ValueError(
+            "'on' columns {} missing from source schema {}.".format(
+                missing, list(names)
+            )
+        )
+
+
+def _validate_unique_row_ids(update_table: pa.Table) -> None:
+    row_id_name = SpecialFields.ROW_ID.name
+    if (
+        pc.count_distinct(update_table.column(row_id_name)).as_py()
+        != update_table.num_rows
+    ):
+        raise ValueError(
+            "MERGE matched multiple source rows to the same target _ROW_ID. "
+            "Deduplicate the source before merging."
+        )
+
+
+def _is_self_merge(target_table, source, target_on, source_on) -> bool:
+    row_id_name = SpecialFields.ROW_ID.name
+    return (
+        _is_table_like(source)
+        and _same_table(target_table, source)
+        and target_on == [row_id_name]
+        and source_on == [row_id_name]
+    )
+
+
+def _is_table_like(obj) -> bool:
+    return hasattr(obj, "new_read_builder") and hasattr(obj, 
"snapshot_manager")
+
+
+def _same_table(left, right) -> bool:
+    if left is right:
+        return True
+    return (
+        getattr(left, "table_path", None) == getattr(right, "table_path", None)
+        and str(getattr(left, "identifier", "")) == str(
+            getattr(right, "identifier", "")
+        )
+        and _current_branch(left) == _current_branch(right)
+    )
+
+
+def _current_branch(table):
+    current_branch = getattr(table, "current_branch", None)
+    return current_branch() if current_branch is not None else None
+
+
+def _rename_with_prefix(table: pa.Table, prefix: str) -> pa.Table:
+    return table.rename_columns(
+        ["{}{}".format(prefix, name) for name in table.schema.names]
+    )
+
+
+def _add_self_merge_source_aliases(
+    target_renamed: pa.Table, orig_names: Sequence[str], row_id_name: str
+) -> pa.Table:
+    columns = list(target_renamed.columns)
+    names = list(target_renamed.schema.names)
+    for orig in orig_names:
+        if orig == row_id_name:
+            continue
+        target_name = "t.{}".format(orig)
+        if target_name in names:
+            idx = names.index(target_name)
+            columns.append(columns[idx])
+            names.append("s.{}".format(orig))
+    return pa.table(columns, names=names)
diff --git a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py 
b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
index 4782b1dc93..edf018a535 100644
--- a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
@@ -39,7 +39,7 @@ except ImportError:
     _HAS_DATAFUSION = False
 
 _SKIP_CONDITION = not _HAS_DATAFUSION
-_SKIP_REASON = "datafusion not installed"
+_SKIP_REASON = "pypaimon[sql] is required for condition expressions"
 
 _TEST_NUM_PARTITIONS = 2
 
@@ -500,21 +500,103 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
             )
         self.assertIn("multiple source rows", str(ctx.exception))
 
-    def test_blob_columns_excluded(self):
-        import types
+    def test_blob_table_merge_into_updates_and_inserts_blob_column(self):
+        blob_schema = pa.schema([
+            ('id', pa.int32()),
+            ('name', pa.string()),
+            ('payload', pa.large_binary()),
+        ])
+        name = f'default.tbl_{uuid.uuid4().hex[:8]}'
+        schema = Schema.from_pyarrow_schema(
+            blob_schema, options=self.de_options)
+        self.catalog.create_table(name, schema, False)
+        self._write(
+            name,
+            pa.Table.from_pydict(
+                {
+                    'id': pa.array([1, 2], type=pa.int32()),
+                    'name': ['Alice', 'Bob'],
+                    'payload': pa.array(
+                        [b'blob-1', b'blob-2'], type=pa.large_binary()),
+                },
+                schema=blob_schema,
+            ),
+        )
+
+        metrics = merge_into(
+            target=name,
+            source=pa.Table.from_pydict(
+                {
+                    'id': pa.array([2, 3], type=pa.int32()),
+                    'name': ['Bobby', 'Cindy'],
+                    'payload': pa.array(
+                        [b'blob-2-updated', b'blob-3'],
+                        type=pa.large_binary()),
+                },
+                schema=blob_schema,
+            ),
+            catalog_options=self.catalog_options,
+            on=['id'],
+            when_matched=[WhenMatched(update='*')],
+            when_not_matched=[WhenNotMatched(insert='*')],
+            num_partitions=_TEST_NUM_PARTITIONS,
+        )
 
-        from pypaimon.ray.data_evolution_merge_into import _blob_col_names
-        from pypaimon.schema.data_types import AtomicType, DataField
+        table = self.catalog.get_table(name)
+        rb = table.new_read_builder()
+        splits = rb.new_scan().plan().splits()
+        out = rb.new_read().to_arrow(splits).sort_by('id').to_pydict()
+        self.assertEqual(out['id'], [1, 2, 3])
+        self.assertEqual(out['name'], ['Alice', 'Bobby', 'Cindy'])
+        self.assertEqual(
+            out['payload'], [b'blob-1', b'blob-2-updated', b'blob-3'])
+        self.assertEqual(metrics, {
+            'num_matched': 1, 'num_inserted': 1, 'num_unchanged': 0,
+        })
 
-        fake_table = types.SimpleNamespace(
-            table_schema=types.SimpleNamespace(
-                fields=[
-                    DataField(0, 'id', AtomicType('INT')),
-                    DataField(1, 'payload', AtomicType('BLOB')),
-                ]
-            )
+    def 
test_blob_table_merge_into_inserts_null_for_unspecified_blob_column(self):
+        blob_schema = pa.schema([
+            ('id', pa.int32()),
+            ('name', pa.string()),
+            ('payload', pa.large_binary()),
+        ])
+        source_schema = pa.schema([
+            ('id', pa.int32()),
+            ('name', pa.string()),
+        ])
+        name = f'default.tbl_{uuid.uuid4().hex[:8]}'
+        schema = Schema.from_pyarrow_schema(
+            blob_schema, options=self.de_options)
+        self.catalog.create_table(name, schema, False)
+
+        metrics = merge_into(
+            target=name,
+            source=pa.Table.from_pydict(
+                {
+                    'id': pa.array([1], type=pa.int32()),
+                    'name': ['Alice'],
+                },
+                schema=source_schema,
+            ),
+            catalog_options=self.catalog_options,
+            on=['id'],
+            when_not_matched=[WhenNotMatched(insert={
+                'id': source_col('id'),
+                'name': source_col('name'),
+            })],
+            num_partitions=_TEST_NUM_PARTITIONS,
         )
-        self.assertEqual({'payload'}, _blob_col_names(fake_table))
+
+        table = self.catalog.get_table(name)
+        rb = table.new_read_builder()
+        splits = rb.new_scan().plan().splits()
+        out = rb.new_read().to_arrow(splits).sort_by('id').to_pydict()
+        self.assertEqual(out['id'], [1])
+        self.assertEqual(out['name'], ['Alice'])
+        self.assertEqual(out['payload'], [None])
+        self.assertEqual(metrics, {
+            'num_matched': 0, 'num_inserted': 1, 'num_unchanged': 0,
+        })
 
     def test_blob_table_feature_update(self):
         blob_schema = pa.schema([
@@ -2147,7 +2229,7 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
         self.assertEqual(out['name'], ['updated', 'updated'])
 
     @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
-    def test_self_merge_blob_target_condition_rejected(self):
+    def test_self_merge_blob_target_condition_allowed(self):
         blob_schema = pa.schema([
             ('id', pa.int32()),
             ('name', pa.string()),
@@ -2169,20 +2251,22 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
             ),
         )
 
-        with self.assertRaises(ValueError) as ctx:
-            merge_into(
-                target=tbl_name,
-                source=tbl_name,
-                catalog_options=self.catalog_options,
-                on=['_ROW_ID'],
-                when_matched=[
-                    WhenMatched(
-                        update={'name': lit('x')},
-                        condition='t.picture IS NOT NULL',
-                    ),
-                ],
-            )
-        self.assertIn('blob', str(ctx.exception).lower())
+        result = merge_into(
+            target=tbl_name,
+            source=tbl_name,
+            catalog_options=self.catalog_options,
+            on=['_ROW_ID'],
+            when_matched=[
+                WhenMatched(
+                    update={'name': lit('x')},
+                    condition='t.picture IS NOT NULL',
+                ),
+            ],
+        )
+
+        self.assertEqual(result['num_matched'], 0)
+        out = self._read_sorted(tbl_name)
+        self.assertEqual(out['name'], ['a'])
 
 
 class TargetProjectionTest(unittest.TestCase):
diff --git a/paimon-python/pypaimon/tests/table_merge_into_test.py 
b/paimon-python/pypaimon/tests/table_merge_into_test.py
new file mode 100644
index 0000000000..73715e735f
--- /dev/null
+++ b/paimon-python/pypaimon/tests/table_merge_into_test.py
@@ -0,0 +1,656 @@
+################################################################################
+#  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.
+################################################################################
+
+import unittest
+
+import pyarrow as pa
+
+from pypaimon.table.data_evolution_merge_into import (
+    WhenMatched,
+    WhenNotMatched,
+    lit,
+    source_col,
+    target_col,
+)
+from pypaimon.tests.data_evolution_test_helpers import (
+    BatchModeMixin,
+    DataEvolutionTestBase,
+    StreamModeMixin,
+)
+
+try:
+    import datafusion  # noqa: F401
+
+    _HAS_DATAFUSION = True
+except ImportError:
+    _HAS_DATAFUSION = False
+
+_SKIP_CONDITION = not _HAS_DATAFUSION
+_SKIP_REASON = "pypaimon[sql] is required for condition expressions"
+
+
+class TableMergeIntoTest(BatchModeMixin, DataEvolutionTestBase, 
unittest.TestCase):
+
+    def _read_sorted(self, table):
+        return self._read_all(table).sort_by("id").to_pydict()
+
+    def _read_projected_sorted(self, table, projection):
+        rb = table.new_read_builder().with_projection(projection)
+        return rb.new_read().to_arrow(rb.new_scan().plan().splits()).sort_by(
+            "id"
+        ).to_pydict()
+
+    def _row_ids_by_id(self, table):
+        rows = self._read_projected_sorted(table, ["id", "_ROW_ID"])
+        return dict(zip(rows["id"], rows["_ROW_ID"]))
+
+    def _merge_and_commit(self, table, source, **kwargs):
+        wb = table.new_batch_write_builder()
+        table_update = wb.new_update()
+        msgs = table_update.merge_into(source, **kwargs)
+        commit = wb.new_commit()
+        commit.commit(msgs)
+        commit.close()
+        return msgs
+
+    def test_table_merge_into_updates_and_inserts(self):
+        target = self._create_table()
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1, 2], type=pa.int32()),
+            "name": ["Alice", "Bob"],
+            "age": pa.array([25, 30], type=pa.int32()),
+            "city": ["NYC", "LA"],
+        }, schema=self.pa_schema))
+
+        source = pa.Table.from_pydict({
+            "id": pa.array([2, 3], type=pa.int32()),
+            "name": ["Bobby", "Cindy"],
+            "age": pa.array([31, 22], type=pa.int32()),
+            "city": ["LA2", "SF"],
+        }, schema=self.pa_schema)
+
+        msgs = self._merge_and_commit(
+            target,
+            source,
+            on=["id"],
+            when_matched=[WhenMatched(update="*")],
+            when_not_matched=[WhenNotMatched(insert="*")],
+        )
+
+        self.assertTrue(msgs)
+        self.assertEqual(
+            {
+                "id": [1, 2, 3],
+                "name": ["Alice", "Bobby", "Cindy"],
+                "age": [25, 31, 22],
+                "city": ["NYC", "LA2", "SF"],
+            },
+            self._read_sorted(target),
+        )
+
+    def test_table_merge_into_insert_only_on_empty_table(self):
+        target = self._create_table()
+        source = pa.Table.from_pydict({
+            "id": pa.array([1, 2], type=pa.int32()),
+            "name": ["Alice", "Bob"],
+            "age": pa.array([25, 30], type=pa.int32()),
+            "city": ["NYC", "LA"],
+        }, schema=self.pa_schema)
+
+        msgs = self._merge_and_commit(
+            target,
+            source,
+            on=["id"],
+            when_not_matched=[WhenNotMatched(insert="*")],
+        )
+
+        self.assertTrue(msgs)
+        self.assertEqual(
+            {
+                "id": [1, 2],
+                "name": ["Alice", "Bob"],
+                "age": [25, 30],
+                "city": ["NYC", "LA"],
+            },
+            self._read_sorted(target),
+        )
+
+    def test_table_merge_into_with_renamed_on_key_fills_insert_key(self):
+        target = self._create_table()
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1], type=pa.int32()),
+            "name": ["Alice"],
+            "age": pa.array([25], type=pa.int32()),
+            "city": ["NYC"],
+        }, schema=self.pa_schema))
+        source = pa.Table.from_pydict({
+            "source_id": pa.array([1, 2], type=pa.int32()),
+            "new_name": ["Alicia", "Bob"],
+            "new_age": pa.array([26, 30], type=pa.int32()),
+            "new_city": ["SF", "LA"],
+        })
+
+        self._merge_and_commit(
+            target,
+            source,
+            on={"id": "source_id"},
+            when_matched=[
+                WhenMatched(update={
+                    "name": source_col("new_name"),
+                    "age": source_col("new_age"),
+                    "city": source_col("new_city"),
+                }),
+            ],
+            when_not_matched=[
+                WhenNotMatched(insert={
+                    "name": source_col("new_name"),
+                    "age": source_col("new_age"),
+                    "city": source_col("new_city"),
+                }),
+            ],
+        )
+
+        self.assertEqual(
+            {
+                "id": [1, 2],
+                "name": ["Alicia", "Bob"],
+                "age": [26, 30],
+                "city": ["SF", "LA"],
+            },
+            self._read_sorted(target),
+        )
+
+    def test_table_merge_into_ignores_target_self_assignment(self):
+        for assignment in [target_col("name"), "t.name"]:
+            with self.subTest(assignment=assignment):
+                target = self._create_table()
+                self._write_arrow(target, pa.Table.from_pydict({
+                    "id": pa.array([1], type=pa.int32()),
+                    "name": ["Alice"],
+                    "age": pa.array([25], type=pa.int32()),
+                    "city": ["NYC"],
+                }, schema=self.pa_schema))
+                row_ids_before = self._row_ids_by_id(target)
+                source = pa.Table.from_pydict({
+                    "id": pa.array([1], type=pa.int32()),
+                    "name": ["Alicia"],
+                    "age": pa.array([26], type=pa.int32()),
+                    "city": ["SF"],
+                }, schema=self.pa_schema)
+
+                msgs = self._merge_and_commit(
+                    target,
+                    source,
+                    on=["id"],
+                    when_matched=[WhenMatched(update={"name": assignment})],
+                )
+
+                self.assertEqual([], msgs)
+                self.assertEqual(
+                    {
+                        "id": [1],
+                        "name": ["Alice"],
+                        "age": [25],
+                        "city": ["NYC"],
+                    },
+                    self._read_sorted(target),
+                )
+                self.assertEqual(row_ids_before, self._row_ids_by_id(target))
+
+    def test_table_merge_into_source_assignment_same_name_is_modified(self):
+        target = self._create_table()
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1], type=pa.int32()),
+            "name": ["Alice"],
+            "age": pa.array([25], type=pa.int32()),
+            "city": ["NYC"],
+        }, schema=self.pa_schema))
+        source = pa.Table.from_pydict({
+            "id": pa.array([1], type=pa.int32()),
+            "name": ["Alicia"],
+            "age": pa.array([26], type=pa.int32()),
+            "city": ["SF"],
+        }, schema=self.pa_schema)
+
+        msgs = self._merge_and_commit(
+            target,
+            source,
+            on=["id"],
+            when_matched=[WhenMatched(update={"name": source_col("name")})],
+        )
+
+        self.assertTrue(msgs)
+        self.assertEqual(["Alicia"], self._read_sorted(target)["name"])
+        self.assertEqual([25], self._read_sorted(target)["age"])
+
+    def test_table_merge_into_rejects_nested_assignment_key(self):
+        target = self._create_table()
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1], type=pa.int32()),
+            "name": ["Alice"],
+            "age": pa.array([25], type=pa.int32()),
+            "city": ["NYC"],
+        }, schema=self.pa_schema))
+        source = pa.Table.from_pydict({
+            "id": pa.array([1], type=pa.int32()),
+            "name": ["Alicia"],
+            "age": pa.array([26], type=pa.int32()),
+            "city": ["SF"],
+        }, schema=self.pa_schema)
+
+        with self.assertRaisesRegex(ValueError, "unknown target column"):
+            target.new_batch_write_builder().new_update().merge_into(
+                source,
+                on=["id"],
+                when_matched=[WhenMatched(update={"name.first": lit("A")})],
+            )
+
+    def test_table_merge_into_rejects_partition_column_update(self):
+        target = self._create_table(partition_keys=["city"])
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1], type=pa.int32()),
+            "name": ["Alice"],
+            "age": pa.array([25], type=pa.int32()),
+            "city": ["NYC"],
+        }, schema=self.pa_schema))
+        source = pa.Table.from_pydict({
+            "id": pa.array([1], type=pa.int32()),
+            "name": ["Alice"],
+            "age": pa.array([26], type=pa.int32()),
+            "city": ["SF"],
+        }, schema=self.pa_schema)
+
+        with self.assertRaisesRegex(ValueError, "partition columns"):
+            target.new_batch_write_builder().new_update().merge_into(
+                source,
+                on=["id"],
+                when_matched=[WhenMatched(update={"city": 
source_col("city")})],
+            )
+
+    def test_table_merge_into_preserves_row_ids_for_matched_rows(self):
+        target = self._create_table()
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1, 2], type=pa.int32()),
+            "name": ["Alice", "Bob"],
+            "age": pa.array([25, 30], type=pa.int32()),
+            "city": ["NYC", "LA"],
+        }, schema=self.pa_schema))
+        row_ids_before = self._row_ids_by_id(target)
+
+        self._merge_and_commit(
+            target,
+            pa.Table.from_pydict({
+                "id": pa.array([2, 3], type=pa.int32()),
+                "name": ["Bobby", "Cindy"],
+                "age": pa.array([31, 22], type=pa.int32()),
+                "city": ["LA2", "SF"],
+            }, schema=self.pa_schema),
+            on=["id"],
+            when_matched=[WhenMatched(update="*")],
+            when_not_matched=[WhenNotMatched(insert="*")],
+        )
+
+        row_ids_after = self._row_ids_by_id(target)
+        self.assertEqual(row_ids_before[1], row_ids_after[1])
+        self.assertEqual(row_ids_before[2], row_ids_after[2])
+        self.assertGreater(row_ids_after[3], max(row_ids_before.values()))
+
+    def test_table_merge_into_second_round_updates_first_round_insert(self):
+        target = self._create_table()
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1], type=pa.int32()),
+            "name": ["Alice"],
+            "age": pa.array([25], type=pa.int32()),
+            "city": ["NYC"],
+        }, schema=self.pa_schema))
+
+        self._merge_and_commit(
+            target,
+            pa.Table.from_pydict({
+                "id": pa.array([2], type=pa.int32()),
+                "name": ["Bob"],
+                "age": pa.array([30], type=pa.int32()),
+                "city": ["LA"],
+            }, schema=self.pa_schema),
+            on=["id"],
+            when_not_matched=[WhenNotMatched(insert="*")],
+        )
+        row_ids_after_insert = self._row_ids_by_id(target)
+
+        self._merge_and_commit(
+            target,
+            pa.Table.from_pydict({
+                "id": pa.array([2], type=pa.int32()),
+                "name": ["Bobby"],
+                "age": pa.array([31], type=pa.int32()),
+                "city": ["LA2"],
+            }, schema=self.pa_schema),
+            on=["id"],
+            when_matched=[WhenMatched(update="*")],
+        )
+
+        self.assertEqual(
+            {
+                "id": [1, 2],
+                "name": ["Alice", "Bobby"],
+                "age": [25, 31],
+                "city": ["NYC", "LA2"],
+            },
+            self._read_sorted(target),
+        )
+        self.assertEqual(row_ids_after_insert, self._row_ids_by_id(target))
+
+    def test_table_merge_into_accepts_paimon_source_table(self):
+        target = self._create_table()
+        source = self._create_table()
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1], type=pa.int32()),
+            "name": ["Alice"],
+            "age": pa.array([25], type=pa.int32()),
+            "city": ["NYC"],
+        }, schema=self.pa_schema))
+        self._write_arrow(source, pa.Table.from_pydict({
+            "id": pa.array([1, 2], type=pa.int32()),
+            "name": ["Alicia", "Bob"],
+            "age": pa.array([26, 30], type=pa.int32()),
+            "city": ["SF", "LA"],
+        }, schema=self.pa_schema))
+
+        msgs = self._merge_and_commit(
+            target,
+            source,
+            on=["id"],
+            when_matched=[WhenMatched(update="*")],
+            when_not_matched=[WhenNotMatched(insert="*")],
+        )
+
+        self.assertTrue(msgs)
+        self.assertEqual(
+            {
+                "id": [1, 2],
+                "name": ["Alicia", "Bob"],
+                "age": [26, 30],
+                "city": ["SF", "LA"],
+            },
+            self._read_sorted(target),
+        )
+
+    def test_table_merge_into_updates_and_inserts_blob_column(self):
+        blob_schema = pa.schema([
+            ("id", pa.int32()),
+            ("name", pa.string()),
+            ("payload", pa.large_binary()),
+        ])
+        target = self._create_table(pa_schema=blob_schema)
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1, 2], type=pa.int32()),
+            "name": ["Alice", "Bob"],
+            "payload": pa.array(
+                [b"blob-1", b"blob-2"], type=pa.large_binary()
+            ),
+        }, schema=blob_schema))
+
+        msgs = self._merge_and_commit(
+            target,
+            pa.Table.from_pydict({
+                "id": pa.array([2, 3], type=pa.int32()),
+                "name": ["Bobby", "Cindy"],
+                "payload": pa.array(
+                    [b"blob-2-updated", b"blob-3"], type=pa.large_binary()
+                ),
+            }, schema=blob_schema),
+            on=["id"],
+            when_matched=[WhenMatched(update="*")],
+            when_not_matched=[WhenNotMatched(insert="*")],
+        )
+
+        self.assertTrue(msgs)
+        self.assertEqual(
+            {
+                "id": [1, 2, 3],
+                "name": ["Alice", "Bobby", "Cindy"],
+                "payload": [b"blob-1", b"blob-2-updated", b"blob-3"],
+            },
+            self._read_projected_sorted(target, ["id", "name", "payload"]),
+        )
+
+    def test_table_merge_into_inserts_null_for_unspecified_blob_column(self):
+        blob_schema = pa.schema([
+            ("id", pa.int32()),
+            ("name", pa.string()),
+            ("payload", pa.large_binary()),
+        ])
+        source_schema = pa.schema([
+            ("id", pa.int32()),
+            ("name", pa.string()),
+        ])
+        target = self._create_table(pa_schema=blob_schema)
+
+        msgs = self._merge_and_commit(
+            target,
+            pa.Table.from_pydict({
+                "id": pa.array([1], type=pa.int32()),
+                "name": ["Alice"],
+            }, schema=source_schema),
+            on=["id"],
+            when_not_matched=[WhenNotMatched(insert={
+                "id": source_col("id"),
+                "name": source_col("name"),
+            })],
+        )
+
+        self.assertTrue(msgs)
+        self.assertEqual(
+            {
+                "id": [1],
+                "name": ["Alice"],
+                "payload": [None],
+            },
+            self._read_projected_sorted(target, ["id", "name", "payload"]),
+        )
+
+    @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+    def test_table_merge_into_condition_clauses(self):
+        target = self._create_table()
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1, 2], type=pa.int32()),
+            "name": ["old_1", "old_2"],
+            "age": pa.array([10, 40], type=pa.int32()),
+            "city": ["NYC", "LA"],
+        }, schema=self.pa_schema))
+
+        source = pa.Table.from_pydict({
+            "id": pa.array([1, 2, 3], type=pa.int32()),
+            "name": ["new_1", "new_2", "new_3"],
+            "age": pa.array([15, 35, 50], type=pa.int32()),
+            "city": ["SF", "SEA", "DAL"],
+        }, schema=self.pa_schema)
+
+        msgs = self._merge_and_commit(
+            target,
+            source,
+            on=["id"],
+            when_matched=[
+                WhenMatched(update={"age": lit(99)}, condition="s.age > 
t.age"),
+                WhenMatched(update={"name": lit("kept")}),
+            ],
+            when_not_matched=[
+                WhenNotMatched(insert="*", condition="s.age > 45"),
+            ],
+        )
+
+        self.assertTrue(msgs)
+        self.assertEqual(
+            {
+                "id": [1, 2, 3],
+                "name": ["old_1", "kept", "new_3"],
+                "age": [99, 40, 50],
+                "city": ["NYC", "LA", "DAL"],
+            },
+            self._read_sorted(target),
+        )
+
+    def test_table_merge_into_duplicate_source_match_rejected(self):
+        target = self._create_table()
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1], type=pa.int32()),
+            "name": ["Alice"],
+            "age": pa.array([25], type=pa.int32()),
+            "city": ["NYC"],
+        }, schema=self.pa_schema))
+
+        source = pa.Table.from_pydict({
+            "id": pa.array([1, 1], type=pa.int32()),
+            "name": ["A1", "A2"],
+            "age": pa.array([26, 27], type=pa.int32()),
+            "city": ["SF", "LA"],
+        }, schema=self.pa_schema)
+
+        with self.assertRaisesRegex(ValueError, "multiple source rows"):
+            target.new_batch_write_builder().new_update().merge_into(
+                source,
+                on=["id"],
+                when_matched=[WhenMatched(update={"age": "s.age"})],
+            )
+
+    def test_table_merge_into_self_merge_by_row_id(self):
+        target = self._create_table()
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1, 2], type=pa.int32()),
+            "name": ["Alice", "Bob"],
+            "age": pa.array([25, 30], type=pa.int32()),
+            "city": ["NYC", "LA"],
+        }, schema=self.pa_schema))
+
+        msgs = self._merge_and_commit(
+            target,
+            target,
+            on=["_ROW_ID"],
+            when_matched=[WhenMatched(update={"name": lit("updated")})],
+        )
+
+        self.assertTrue(msgs)
+        self.assertEqual(["updated", "updated"], 
self._read_sorted(target)["name"])
+
+
+class StreamTableMergeIntoTest(StreamModeMixin, DataEvolutionTestBase, 
unittest.TestCase):
+
+    def _read_sorted(self, table):
+        return self._read_all(table).sort_by("id").to_pydict()
+
+    def test_stream_table_update_merge_into(self):
+        target = self._create_table()
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1], type=pa.int32()),
+            "name": ["Alice"],
+            "age": pa.array([25], type=pa.int32()),
+            "city": ["NYC"],
+        }, schema=self.pa_schema))
+
+        source = pa.Table.from_pydict({
+            "id": pa.array([1, 2], type=pa.int32()),
+            "name": ["Alicia", "Bob"],
+            "age": pa.array([26, 30], type=pa.int32()),
+            "city": ["SF", "LA"],
+        }, schema=self.pa_schema)
+
+        base_snapshot_id = self._latest_snapshot_id(target)
+        wb = target.new_stream_write_builder()
+        cid = self._next_commit_id()
+        msgs = wb.new_update().merge_into(
+            source,
+            on=["id"],
+            when_matched=[WhenMatched(update="*")],
+            when_not_matched=[WhenNotMatched(insert="*")],
+            commit_identifier=cid,
+        )
+        commit = wb.new_commit()
+        commit.commit(msgs, cid)
+        commit.close()
+
+        self._assert_stream_builder_snapshots(
+            target, wb, base_snapshot_id, [cid]
+        )
+        self.assertEqual(
+            {
+                "id": [1, 2],
+                "name": ["Alicia", "Bob"],
+                "age": [26, 30],
+                "city": ["SF", "LA"],
+            },
+            self._read_sorted(target),
+        )
+
+    def test_stream_table_update_merge_into_multiple_rounds(self):
+        target = self._create_table()
+        self._write_arrow(target, pa.Table.from_pydict({
+            "id": pa.array([1], type=pa.int32()),
+            "name": ["Alice"],
+            "age": pa.array([25], type=pa.int32()),
+            "city": ["NYC"],
+        }, schema=self.pa_schema))
+
+        base_snapshot_id = self._latest_snapshot_id(target)
+        wb = target.new_stream_write_builder()
+        commit = wb.new_commit()
+
+        cid1 = self._next_commit_id()
+        msgs1 = wb.new_update().merge_into(
+            pa.Table.from_pydict({
+                "id": pa.array([2], type=pa.int32()),
+                "name": ["Bob"],
+                "age": pa.array([30], type=pa.int32()),
+                "city": ["LA"],
+            }, schema=self.pa_schema),
+            on=["id"],
+            when_not_matched=[WhenNotMatched(insert="*")],
+            commit_identifier=cid1,
+        )
+        commit.commit(msgs1, cid1)
+
+        cid2 = self._next_commit_id()
+        msgs2 = wb.new_update().merge_into(
+            pa.Table.from_pydict({
+                "id": pa.array([2], type=pa.int32()),
+                "name": ["Bobby"],
+                "age": pa.array([31], type=pa.int32()),
+                "city": ["LA2"],
+            }, schema=self.pa_schema),
+            on=["id"],
+            when_matched=[WhenMatched(update="*")],
+            commit_identifier=cid2,
+        )
+        commit.commit(msgs2, cid2)
+        commit.close()
+
+        self._assert_stream_builder_snapshots(
+            target, wb, base_snapshot_id, [cid1, cid2]
+        )
+        self.assertEqual(
+            {
+                "id": [1, 2],
+                "name": ["Alice", "Bobby"],
+                "age": [25, 31],
+                "city": ["NYC", "LA2"],
+            },
+            self._read_sorted(target),
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/paimon-python/pypaimon/write/table_update.py 
b/paimon-python/pypaimon/write/table_update.py
index fe1bbee83a..861396e0ba 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -16,7 +16,7 @@
 # under the License.
 
 from collections import defaultdict
-from typing import Any, List, Mapping, Optional, Tuple
+from typing import Any, List, Mapping, Optional, Sequence, Tuple
 
 import pyarrow
 import pyarrow as pa
@@ -185,6 +185,26 @@ class TableUpdate:
             self.table, self.commit_user, commit_identifier
         ).upsert(table, upsert_keys, self.update_cols)
 
+    def _merge_into(
+            self,
+            source: Any,
+            on,
+            when_matched: Sequence,
+            when_not_matched: Sequence,
+            commit_identifier: int,
+    ) -> List[CommitMessage]:
+        from pypaimon.table.data_evolution_merge_into import merge_into
+
+        return merge_into(
+            self.table,
+            source,
+            on=on,
+            when_matched=when_matched,
+            when_not_matched=when_not_matched,
+            commit_user=self.commit_user,
+            commit_identifier=commit_identifier,
+        )
+
     def _update_by_predicate(
             self,
             predicate: Optional[Predicate],
@@ -342,6 +362,23 @@ class BatchTableUpdate(TableUpdate):
             predicate, assignments, BATCH_COMMIT_IDENTIFIER
         )
 
+    def merge_into(
+            self,
+            source: Any,
+            *,
+            on,
+            when_matched: Sequence = (),
+            when_not_matched: Sequence = (),
+    ) -> List[CommitMessage]:
+        """Prepare batch MERGE INTO commit messages."""
+        return self._merge_into(
+            source,
+            on,
+            when_matched,
+            when_not_matched,
+            BATCH_COMMIT_IDENTIFIER,
+        )
+
 
 class StreamTableUpdate(TableUpdate):
     """Stream-mode table update; the same instance may drive many rounds,
@@ -378,6 +415,24 @@ class StreamTableUpdate(TableUpdate):
             predicate, assignments, commit_identifier
         )
 
+    def merge_into(
+            self,
+            source: Any,
+            *,
+            on,
+            when_matched: Sequence = (),
+            when_not_matched: Sequence = (),
+            commit_identifier: int,
+    ) -> List[CommitMessage]:
+        """Prepare stream MERGE INTO commit messages."""
+        return self._merge_into(
+            source,
+            on,
+            when_matched,
+            when_not_matched,
+            commit_identifier,
+        )
+
 
 class ShardTableUpdator:
 


Reply via email to