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 3357c6f956 [Python] Align selected write nullability with Java and 
Native (#10666)
3357c6f956 is described below

commit 3357c6f956a4269997d0eb2a541c7353b2d411af
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 19 23:00:48 2026 +0800

    [Python] Align selected write nullability with Java and Native (#10666)
---
 .../tests/native_update_source_nullability_test.py | 263 +++++++++++++++++++++
 .../write/retained_delete_nullability_test.py      | 164 +++++++++++++
 .../tests/write/row_kind_nullability_test.py       |  17 +-
 paimon-python/pypaimon/write/row_kind.py           |  25 +-
 .../pypaimon/write/table_update_by_row_id.py       |  26 +-
 paimon-python/pypaimon/write/table_write.py        |   4 +
 .../pypaimon/write/writer/key_value_data_writer.py |   4 +-
 7 files changed, 490 insertions(+), 13 deletions(-)

diff --git 
a/paimon-python/pypaimon/tests/native_update_source_nullability_test.py 
b/paimon-python/pypaimon/tests/native_update_source_nullability_test.py
new file mode 100644
index 0000000000..e1f8756b6b
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_update_source_nullability_test.py
@@ -0,0 +1,263 @@
+# 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.
+
+"""Source Arrow nullability must not validate values discarded by update 
actions."""
+
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.table.data_evolution_merge_into import WhenMatched, source_col
+from pypaimon.table.row.generic_row import GenericRow
+
+
+pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
+_NATIVE = pytest.mark.skipif(not native_runtime_available(), 
reason='pypaimon-rust not installed')
+_ENGINES = [False, pytest.param(True, marks=[pytest.mark.native_plan, 
_NATIVE])]
+_OPERATIONS = ['upsert_arrow', 'upsert_rows', 'merge']
+
+
+def _table(catalog, native, value_nullable=False):
+    schema = pa.schema([pa.field('id', pa.int32(), nullable=False),
+                        pa.field('value', pa.int32(), nullable=value_nullable),
+                        pa.field('keep', pa.int32(), nullable=False)])
+    catalog.create_table('default.updates', Schema.from_pyarrow_schema(schema, 
options={
+        'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
+        'write.native.enabled': str(native).lower(), 'commit.native.enabled': 
'false',
+        'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
+    }), False)
+    table = catalog.get_table('default.updates')
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.Table.from_pylist([dict(id=1, value=10, 
keep=100)], schema=schema))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return table, schema
+
+
+def _source(schema, operation, invalid):
+    if invalid:
+        rows = [dict(id=1, value=None, keep=100)]
+    elif operation == 'merge':
+        # The second row does not match and there is no INSERT clause. Its
+        # NULL cannot be validated as though it were written to the target.
+        rows = [dict(id=1, value=20, keep=None), dict(id=2, value=None, 
keep=None)]
+    else:
+        # Last-write-wins discards the first invalid value. The selected row
+        # updates only value, so keep is never a target assignment.
+        rows = [dict(id=1, value=None, keep=None), dict(id=1, value=20, 
keep=None)]
+    if operation == 'upsert_rows':
+        return rows
+    source = pa.Table.from_pylist([dict(id=0, value=0, keep=0)] + rows, 
schema=schema).slice(1)
+    source.validate(full=True)
+    return source.replace_schema_metadata({'origin': 'external-update'})
+
+
+def _messages(table, source, native, stream, operation):
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update()
+    kwargs = dict(commit_identifier=31) if stream else {}
+    with ExitStack() as stack:
+        if native:
+            for method in ('_upsert_partition', '_upsert_row_partition'):
+                
stack.enter_context(patch('pypaimon.write.table_upsert_by_key.TableUpsertByKey.'
 + method,
+                                          side_effect=AssertionError('Python 
upsert fallback')))
+            
stack.enter_context(patch('pypaimon.table.data_evolution_merge_into._build_tables',
+                                      side_effect=AssertionError('Python MERGE 
fallback')))
+        if operation == 'merge':
+            messages = update.merge_into(source, on=['id'], when_matched=[
+                WhenMatched.update({'value': source_col('value')})], **kwargs)
+        elif operation == 'upsert_rows':
+            rows = [GenericRow([row[name] for name in table.field_names], 
table.fields) for row in source]
+            messages = update.with_update_type(['value']).upsert_by_key(rows, 
['id'], **kwargs)
+        else:
+            messages = 
update.with_update_type(['value']).upsert_by_arrow_with_key(source, ['id'], 
**kwargs)
+    return builder, messages
+
+
+def _rows(table):
+    builder = table.new_read_builder()
+    return 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist()
+
+
[email protected]('native', _ENGINES, ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('operation', _OPERATIONS)
+def test_update_validates_selected_target_values_not_source_transport(
+        native_rest_catalog, native, stream, operation):
+    table, schema = _table(native_rest_catalog, native)
+    source = _source(schema, operation, invalid=False)
+    builder, messages = _messages(table, source, native, stream, operation)
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 31) if stream else commit.commit(messages)
+    finally:
+        commit.close()
+    assert _rows(table) == [dict(id=1, value=20, keep=100)]
+    if isinstance(source, pa.Table):
+        assert source.schema.metadata == {b'origin': b'external-update'}
+        assert all(not field.nullable for field in source.schema)
+
+
[email protected]('native', _ENGINES, ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('operation', _OPERATIONS)
+def test_selected_target_not_null_value_is_still_required(
+        native_rest_catalog, native, stream, operation):
+    table, schema = _table(native_rest_catalog, native)
+    snapshot = table.snapshot_manager().get_latest_snapshot().id
+    with pytest.raises(ValueError, match='(?i)null'):
+        _messages(table, _source(schema, operation, invalid=True), native, 
stream, operation)
+    assert table.snapshot_manager().get_latest_snapshot().id == snapshot
+    assert _rows(table) == [dict(id=1, value=10, keep=100)]
+
+
[email protected]('native', _ENGINES, ids=['python', 'native'])
[email protected]('operation', _OPERATIONS)
+def test_selected_nullable_target_accepts_null(native_rest_catalog, native, 
operation):
+    table, schema = _table(native_rest_catalog, native, value_nullable=True)
+    builder, messages = _messages(table, _source(schema, operation, 
invalid=True), native, False, operation)
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages)
+    finally:
+        commit.close()
+    assert _rows(table) == [dict(id=1, value=None, keep=100)]
+
+
[email protected]('native', _ENGINES, ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('reverse', [False, True], ids=['null-last', 
'null-first'])
+def test_merge_chunked_source_keeps_one_transport_schema(
+        native_rest_catalog, native, stream, reverse):
+    table, schema = _table(native_rest_catalog, native)
+    schema = schema.with_metadata({'origin': 'chunked-merge'})
+    # Each chunk needs a different relaxed transport field. The NULL key and
+    # unmatched value never reach a target assignment, regardless of chunk 
order.
+    rows = [dict(id=1, value=20, keep=100), dict(id=2, value=None, keep=None),
+            dict(id=None, value=30, keep=100)]
+    chunks = [pa.RecordBatch.from_pylist([dict(id=0, value=0, keep=0), row], 
schema=schema).slice(1)
+              for row in rows]
+    source = pa.Table.from_batches(chunks[::-1] if reverse else chunks)
+    encoded = pa.chunked_array([pa.DictionaryArray.from_arrays(
+        pa.array([0], type=pa.int8()), pa.array([None], type=pa.int32())) for 
_ in chunks])
+    source = source.append_column(pa.field('encoded_unused', encoded.type, 
nullable=False), encoded)
+    schema = source.schema
+    assert source.column('encoded_unused').null_count == 0
+    assert source.column('encoded_unused').to_pylist() == [None, None, None]
+    assert len(source.to_batches()) == 3
+    builder, messages = _messages(table, source, native, stream, 'merge')
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 31) if stream else commit.commit(messages)
+    finally:
+        commit.close()
+    assert _rows(table) == [dict(id=1, value=20, keep=100)]
+    assert source.schema.equals(schema, check_metadata=True)
+
+
+def _blob_table(catalog, native, payload_type, nullable, before):
+    schema = pa.schema([pa.field('id', pa.int32()),
+                        pa.field('payload', payload_type, nullable=nullable)])
+    catalog.create_table('default.blobs', Schema.from_pyarrow_schema(schema, 
options={
+        'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
+        'write.native.enabled': str(native).lower(), 'commit.native.enabled': 
'false',
+        'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
+    }), False)
+    table = catalog.get_table('default.blobs')
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.Table.from_pylist([dict(id=1, payload=before)], 
schema=schema))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return table, schema
+
+
[email protected]('native', _ENGINES, ids=['python', 'native'])
[email protected]('input_', ['arrow', 'row'])
[email protected]('nullable', [False, True], ids=['not-null', 
'nullable'])
+def test_selected_blob_nullability_applies_to_dedicated_files(
+        native_rest_catalog, native, input_, nullable):
+    table, schema = _blob_table(native_rest_catalog, native, 
pa.large_binary(), nullable, b'before')
+    snapshot = table.snapshot_manager().get_latest_snapshot().id
+    builder = table.new_batch_write_builder()
+    with ExitStack() as stack:
+        if native:
+            stack.enter_context(patch(
+                
'pypaimon.write.table_update_by_row_id.TableUpdateByRowId._load_existing_files_info',
+                side_effect=AssertionError('Python row-ID update fallback')))
+        updater = builder.new_update().new_update_by_row_id()
+
+        def update():
+            if input_ == 'row':
+                return updater.update_row_columns(GenericRow([None], 
[table.field_dict['payload']]),
+                                                  [0], ['payload'])
+            source = pa.Table.from_arrays([pa.array([0], type=pa.int64()),
+                                          pa.array([None], 
type=pa.large_binary())], schema=pa.schema([
+                                              pa.field('_ROW_ID', pa.int64()), 
schema.field('payload')]))
+            return updater.update_columns(source, ['payload'])
+
+        if nullable:
+            messages = update()
+        else:
+            with pytest.raises(ValueError, match='(?i)null'):
+                update()
+            messages = []
+    if nullable:
+        commit = builder.new_commit()
+        try:
+            commit.commit(messages)
+        finally:
+            commit.close()
+    else:
+        assert table.snapshot_manager().get_latest_snapshot().id == snapshot
+    assert _rows(table) == [dict(id=1, payload=None if nullable else 
b'before')]
+
+
[email protected]('native', _ENGINES, ids=['python', 'native'])
[email protected]('payload_type,before,after', [
+    (pa.list_(pa.large_binary()), [b'before'], [None]),
+    (pa.map_(pa.string(), pa.large_binary()), [('key', b'before')], [('key', 
None)]),
+], ids=['array', 'map'])
+def test_not_null_blob_containers_keep_nullable_children(
+        native_rest_catalog, native, payload_type, before, after):
+    table, schema = _blob_table(native_rest_catalog, native, payload_type, 
False, before)
+    builder = table.new_batch_write_builder()
+    source = pa.Table.from_arrays([pa.array([0], type=pa.int64()), 
pa.array([after], type=payload_type)],
+                                  schema=pa.schema([pa.field('_ROW_ID', 
pa.int64()), schema.field('payload')]))
+    with ExitStack() as stack:
+        if native:
+            stack.enter_context(patch(
+                
'pypaimon.write.table_update_by_row_id.TableUpdateByRowId._load_existing_files_info',
+                side_effect=AssertionError('Python row-ID update fallback')))
+        messages = 
builder.new_update().new_update_by_row_id().update_columns(source, ['payload'])
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages)
+    finally:
+        commit.close()
+    assert _rows(table) == [dict(id=1, payload=after)]
diff --git 
a/paimon-python/pypaimon/tests/write/retained_delete_nullability_test.py 
b/paimon-python/pypaimon/tests/write/retained_delete_nullability_test.py
new file mode 100644
index 0000000000..061cd82ec1
--- /dev/null
+++ b/paimon-python/pypaimon/tests/write/retained_delete_nullability_test.py
@@ -0,0 +1,164 @@
+# 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.
+
+"""Deduplicate tombstones persist nullable values without changing logical NOT 
NULL."""
+
+from pathlib import Path
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.row.row_kind import RowKind
+from pypaimon.write.native_write import NativeTableWrite
+
+
+pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
+_NATIVE = [False, pytest.param(True, marks=[pytest.mark.native_plan, 
pytest.mark.skipif(
+    not native_runtime_available(), reason='pypaimon-rust not installed')])]
+
+
+def _table(tmp_path, native=False, **options):
+    schema = pa.schema([pa.field('id', pa.int32(), nullable=False),
+                        pa.field('seq', pa.int32(), nullable=False),
+                        pa.field('value', pa.string(), nullable=False),
+                        pa.field('op', pa.string())])
+    config = {'bucket': '1', 'sequence.field': 'seq', 'ignore-delete': 'false',
+              'rowkind.field': 'op',
+              'write.native.enabled': str(native).lower(), 
'commit.native.enabled': 'false',
+              'read.native.enabled': 'false', 'scan.native-plan.enabled': 
'false'}
+    config.update(options)
+    config = {key: value for key, value in config.items() if value is not None}
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        schema, primary_keys=['id'], options=config), False)
+    return catalog.get_table('default.t'), schema
+
+
+def _write(writer, table, schema, rows, input_, bucket=None):
+    if input_ == 'row':
+        for values in rows:
+            row = GenericRow([values[field.name] for field in table.fields], 
table.fields,
+                             RowKind.from_string(values['op']))
+            writer.write_row(row)
+    elif input_ == 'pandas':
+        import pandas as pd
+        writer.write_pandas(pd.DataFrame.from_records(rows, 
columns=schema.names))
+    else:
+        if input_ == 'large_string':
+            schema = pa.schema([field.with_type(pa.large_string()) if 
pa.types.is_string(field.type)
+                                else field for field in schema])
+        data = pa.Table.from_pylist(rows, schema=schema)
+        writer.write_arrow(data.to_batches()[0] if input_ == 'batch' else 
data, bucket)
+
+
+def _read(table, predicate=None):
+    reader = table.new_read_builder()
+    if predicate is not None:
+        reader.with_filter(predicate)
+    return 
reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist()
+
+
[email protected]('native', _NATIVE)
[email protected]('input_', ['row', 'batch', 'table', 'large_string', 
'pandas'])
[email protected]('stream', [False, True])
+def test_retained_delete_persists_only_required_values(tmp_path, native, 
input_, stream):
+    table, schema = _table(tmp_path, native)
+    for identifier, (seq, value, kind, expected) in enumerate([
+            (10, 'before', '+I', [dict(id=1, seq=10, value='before', 
op='+I')]),
+            (9, None, '-D', [dict(id=1, seq=10, value='before', op='+I')]),
+            (11, None, '-D', [])], 1):
+        builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+        writer, commit = builder.new_write(), builder.new_commit()
+        try:
+            assert isinstance(writer, NativeTableWrite) == native
+            _write(writer, table, schema, [dict(id=1, seq=seq, value=value, 
op=kind)], input_)
+            messages = writer.prepare_commit(identifier) if stream else 
writer.prepare_commit()
+            # Data and INPUT changelog writers share the relaxed value type.
+            for path in Path(tmp_path).rglob('*.parquet'):
+                physical = pq.read_schema(path)
+                assert physical.field('value').nullable
+                assert not physical.field('id').nullable
+                assert not physical.field('seq').nullable
+            commit.commit(messages, identifier) if stream else 
commit.commit(messages)
+            assert _read(table) == expected
+            predicate = 
table.new_read_builder().new_predicate_builder().equal('value', 'before')
+            assert _read(table, predicate) == expected
+            assert not table.table_schema.fields[2].type.nullable
+            if native:
+                assert writer._python_writer is None
+        finally:
+            writer.close()
+            commit.close()
+
+
[email protected]('native', _NATIVE)
[email protected]('input_', ['row', 'batch', 'large_string', 'pandas'])
[email protected]('kind,field', [
+    ('-D', 'id'), ('-D', 'seq'), ('-U', 'value'), ('+U', 'value'), ('+I', 
'value')])
+def test_retained_events_keep_logical_not_null_checks(tmp_path, native, kind, 
field, input_):
+    table, schema = _table(tmp_path, native)
+    writer = table.new_batch_write_builder().new_write()
+    row = dict(id=1, seq=1, value='ok', op=kind)
+    row[field] = None
+    try:
+        with pytest.raises((ValueError, RuntimeError), match='non-null 
column|non-nullable'):
+            _write(writer, table, schema, [row], input_)
+        assert not list(Path(tmp_path).rglob('*.parquet'))
+    finally:
+        writer.abort()
+
+
[email protected]('native', _NATIVE)
+def test_internal_row_delete_preserves_input_changelog_nulls(tmp_path, native):
+    table, schema = _table(tmp_path, native, **{'rowkind.field': None, 
'changelog-producer': 'input'})
+    for seq, value, kind in [(1, 'before', '+I'), (2, None, '-D')]:
+        builder = table.new_batch_write_builder()
+        writer, commit = builder.new_write(), builder.new_commit()
+        try:
+            _write(writer, table, schema, [dict(id=1, seq=seq, value=value, 
op=kind)], 'row')
+            messages = writer.prepare_commit()
+            files = [file for message in messages for file in 
message.changelog_files]
+            assert len(files) == 1
+            path = next(Path(tmp_path).rglob(files[0].file_name))
+            assert pq.read_table(path, columns=['_VALUE_KIND', 
'value']).to_pydict() == {
+                '_VALUE_KIND': [RowKind.from_string(kind).value], 'value': 
[value]}
+            commit.commit(messages)
+        finally:
+            writer.close()
+            commit.close()
+    assert _read(table) == []
+
+
[email protected]('native', _NATIVE)
[email protected]('engine', ['partial-update', 'aggregation', 
'first-row'])
+def test_delete_null_exception_does_not_relax_other_write_modes(tmp_path, 
native, engine):
+    options = {'merge-engine': engine}
+    if engine == 'first-row':
+        options['sequence.field'] = None
+    table, schema = _table(tmp_path, native, **options)
+    writer = table.new_batch_write_builder().new_write()
+    try:
+        with pytest.raises((ValueError, RuntimeError), match=r'non-null 
column\(value\)'):
+            _write(writer, table, schema, [dict(id=1, seq=1, value=None, 
op='-D')], 'table')
+        assert not list(Path(tmp_path).rglob('*.parquet'))
+    finally:
+        writer.abort()
diff --git a/paimon-python/pypaimon/tests/write/row_kind_nullability_test.py 
b/paimon-python/pypaimon/tests/write/row_kind_nullability_test.py
index 62652600f1..17596a33ae 100644
--- a/paimon-python/pypaimon/tests/write/row_kind_nullability_test.py
+++ b/paimon-python/pypaimon/tests/write/row_kind_nullability_test.py
@@ -187,11 +187,22 @@ def 
test_pandas_non_filtering_writers_keep_not_null_validation(tmp_path, primary
     writer = table.new_batch_write_builder().new_write()
     facade = NativeTableWrite(table, 'native-user', None, False, Mock())
     try:
-        for candidate in [writer, facade]:
+        with pytest.raises(ValueError, match='non-null column|non-nullable'):
+            writer.write_pandas(data)
+        if primary_key:
+            # The native facade preserves nullable KV transport; the actual
+            # core writer enforces logical INSERT nullability, covered by the
+            # retained DELETE integration tests.
+            facade.write_pandas(data)
+            transported = facade._native_writer.write_arrow.call_args[0][0]
+            assert transported.column('value').to_pylist() == [None]
+            assert transported.schema.field('value').nullable
+            assert not facade._schema.field('value').nullable
+        else:
             with pytest.raises(ValueError, match='non-nullable'):
-                candidate.write_pandas(data)
+                facade.write_pandas(data)
+            facade._native_writer.write_arrow.assert_not_called()
         assert not writer.file_store_write.data_writers
-        facade._native_writer.write_arrow.assert_not_called()
     finally:
         writer.close()
 
diff --git a/paimon-python/pypaimon/write/row_kind.py 
b/paimon-python/pypaimon/write/row_kind.py
index ad66bf0cbc..34a9b24c0e 100644
--- a/paimon-python/pypaimon/write/row_kind.py
+++ b/paimon-python/pypaimon/write/row_kind.py
@@ -56,22 +56,22 @@ def filter_write_batch(table, data):
         return data
     options = table.options
     raw = options.options.to_map()
-    if 'rowkind.field' not in raw:
-        return data
     kinds = row_kinds(options, data)
     ignore_delete = options.ignore_delete()
     ignore_before = str(raw.get('ignore-update-before', 'false')).lower() == 
'true'
-    if not ignore_delete and not ignore_before:
-        return data
     # Java checks top-level NULLs before its RowKindFilter. A DELETE in a
     # non-cross-partition deduplicate table only requires non-NULL keys and
     # sequence fields; UPDATE_BEFORE retains all declared NOT NULL fields.
     for name, check_delete in _not_null_fields(table):
+        if name not in data.schema.names:
+            continue
         invalid = data.column(name).is_null()
         if not check_delete:
             invalid = pc.and_(invalid, pc.not_equal(kinds, 
RowKind.DELETE.value))
         if pc.any(invalid).as_py():
             raise ValueError('Cannot write null to non-null column(%s)' % name)
+    if not ignore_delete and not ignore_before:
+        return data
     ignored = [RowKind.UPDATE_BEFORE.value]
     if ignore_delete:
         ignored.append(RowKind.DELETE.value)
@@ -109,10 +109,9 @@ def skip_write_row(table, values, row_kind=RowKind.INSERT):
         if row_kind_field_name is None
         else RowKind.from_string(values[row_kind_field_name]).value
     )
-    if options.ignore_delete() or str(raw.get('ignore-update-before', 
'false')).lower() == 'true':
-        for name, check_delete in _not_null_fields(table):
-            if (check_delete or kind != RowKind.DELETE.value) and values[name] 
is None:
-                raise ValueError('Cannot write null to non-null column(%s)' % 
name)
+    for name, check_delete in _not_null_fields(table):
+        if (check_delete or kind != RowKind.DELETE.value) and values.get(name) 
is None:
+            raise ValueError('Cannot write null to non-null column(%s)' % name)
     return _is_filtered(kind, options.ignore_delete(),
                         str(raw.get('ignore-update-before', 'false')).lower() 
== 'true')
 
@@ -127,5 +126,15 @@ def _not_null_fields(table):
             for field in table.fields if not field.type.nullable]
 
 
+def write_value_schema(table, schema):
+    """Java's KV value type can persist DELETEs with only keys and sequence."""
+    if (not table.is_primary_key_table or table.cross_partition_update
+            or table.options.merge_engine() != MergeEngine.DEDUPLICATE):
+        return schema
+    required = set(table.table_schema.primary_keys + 
table.options.sequence_field())
+    return pa.schema([field if field.name in required else 
field.with_nullable(True)
+                      for field in schema], metadata=schema.metadata)
+
+
 def _is_filtered(kind, ignore_delete, ignore_before):
     return (ignore_delete and kind in (1, 3)) or (ignore_before and kind == 1)
diff --git a/paimon-python/pypaimon/write/table_update_by_row_id.py 
b/paimon-python/pypaimon/write/table_update_by_row_id.py
index ff721625d9..f39be2270e 100644
--- a/paimon-python/pypaimon/write/table_update_by_row_id.py
+++ b/paimon-python/pypaimon/write/table_update_by_row_id.py
@@ -671,7 +671,25 @@ class TableUpdateByRowId:
         # write types at the writer boundary, including untouched input 
batches.
         write_schema = PyarrowFieldParser.from_paimon_schema(
             [self.table.field_dict[name] for name in data.schema.names])
-        return normalize_arrow_write_nullability(data, write_schema)
+        normalized = normalize_arrow_write_nullability(data, write_schema)
+        # Reads relax field nullability for schema evolution. A projected
+        # update must still enforce the declared write type, as Java's
+        # TableWrite.withWriteType does for every selected output column.
+        for field, column in zip(write_schema, normalized.columns):
+            self._check_write_not_null(field.name, column.null_count > 0)
+        if any(actual.nullable != expected.nullable
+               for actual, expected in zip(normalized.schema, write_schema)):
+            fields = [actual.with_nullable(expected.nullable)
+                      for actual, expected in zip(normalized.schema, 
write_schema)]
+            schema = pa.schema(fields, metadata=normalized.schema.metadata)
+            if isinstance(normalized, pa.RecordBatch):
+                return pa.RecordBatch.from_arrays(normalized.columns, 
schema=schema)
+            return pa.Table.from_arrays(normalized.columns, schema=schema)
+        return normalized
+
+    def _check_write_not_null(self, column_name, has_null):
+        if not self.table.field_dict[column_name].type.nullable and has_null:
+            raise ValueError('Cannot write null to non-null column(%s)' % 
column_name)
 
     @classmethod
     def _merge_chunked_column(
@@ -929,6 +947,12 @@ class TableUpdateByRowId:
             blob_columns_with_baseline,
         )
 
+        # Dedicated Blob files bypass the ordinary Arrow writer. Validate
+        # the merged values that will be written, including row-based inputs,
+        # while placeholders and NULL children of non-NULL containers remain 
valid.
+        for column_name, values in blob_columns.items():
+            self._check_write_not_null(column_name, any(value is None for 
value in values))
+
         partition_tuple = tuple(partition.values)
         new_files = []
         file_store_write = None
diff --git a/paimon-python/pypaimon/write/table_write.py 
b/paimon-python/pypaimon/write/table_write.py
index 6c02c33a0b..5f6f35e2b4 100644
--- a/paimon-python/pypaimon/write/table_write.py
+++ b/paimon-python/pypaimon/write/table_write.py
@@ -40,6 +40,8 @@ def _pandas_write_schema(table, schema):
     """Keep ignored events available for the normal row-kind NULL checks."""
     if table is None or not table.is_primary_key_table:
         return schema
+    from pypaimon.write.row_kind import write_value_schema
+    schema = write_value_schema(table, schema)
     options = table.options
     raw = options.options.to_map()
     if (raw.get('rowkind.field') is None
@@ -375,6 +377,8 @@ class TableWrite:
             fields.append(pa.field(field.name, field.type, 
nullable=declared.nullable,
                                    metadata=declared.metadata))
         schema = pa.schema(fields, metadata=self.table_pyarrow_schema.metadata)
+        from pypaimon.write.row_kind import write_value_schema
+        schema = write_value_schema(self.table, schema)
         if data.schema.equals(schema, check_metadata=True):
             return data
         if isinstance(data, pa.RecordBatch):
diff --git a/paimon-python/pypaimon/write/writer/key_value_data_writer.py 
b/paimon-python/pypaimon/write/writer/key_value_data_writer.py
index f513e148d6..63cddacaa6 100644
--- a/paimon-python/pypaimon/write/writer/key_value_data_writer.py
+++ b/paimon-python/pypaimon/write/writer/key_value_data_writer.py
@@ -298,6 +298,8 @@ class KeyValueDataWriter(DataWriter):
     def _add_system_fields(self, data: pa.RecordBatch) -> pa.RecordBatch:
         """Add system fields: _KEY_{pk_key}, _SEQUENCE_NUMBER, _VALUE_KIND."""
         num_rows = data.num_rows
+        from pypaimon.write.row_kind import write_value_schema
+        value_schema = write_value_schema(self.table, data.schema)
 
         new_arrays = []
         new_fields = []
@@ -325,7 +327,7 @@ class KeyValueDataWriter(DataWriter):
             if data.schema.field(i).name == SpecialFields.VALUE_KIND.name:
                 continue
             new_arrays.append(data.column(i))
-            new_fields.append(data.schema.field(i))
+            new_fields.append(value_schema.field(i))
 
         return pa.RecordBatch.from_arrays(new_arrays, 
schema=pa.schema(new_fields))
 

Reply via email to