This is an automated email from the ASF dual-hosted git repository.

yuxiqian pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git


The following commit(s) were added to refs/heads/master by this push:
     new 29c7fbd12 [FLINK-39567][connect/paimon] Add BLOB fields support (#4391)
29c7fbd12 is described below

commit 29c7fbd12df26311e4bb385458019765e2e88c75
Author: Kunni <[email protected]>
AuthorDate: Tue Sep 22 13:16:37 2026 +0800

    [FLINK-39567][connect/paimon] Add BLOB fields support (#4391)
---
 .../flink-cdc-pipeline-connector-paimon/pom.xml    |  44 ++
 .../paimon/sink/PaimonDataSinkFactory.java         |   2 +-
 .../paimon/sink/PaimonMetadataApplier.java         | 111 ++++-
 .../paimon/sink/SchemaChangeProvider.java          | 148 ++++++-
 .../sink/v2/PaimonRecordEventSerializer.java       |  44 +-
 .../connectors/paimon/sink/v2/PaimonWriter.java    |   1 +
 .../paimon/sink/v2/PaimonWriterHelper.java         | 117 ++++-
 .../connectors/paimon/sink/v2/TableSchemaInfo.java |  18 +-
 .../paimon/sink/v2/blob/BlobWriteContext.java      | 145 +++++++
 .../sink/v2/bucket/BucketAssignOperator.java       |   9 +-
 .../paimon/sink/PaimonMetadataApplierTest.java     | 475 +++++++++++++++++++++
 .../paimon/sink/v2/AppendOnlyTableITCase.java      | 209 +++++++++
 .../paimon/sink/v2/PaimonSinkITCase.java           |  18 +-
 .../paimon/sink/v2/PaimonWriterHelperTest.java     | 363 ++++++++++++++++
 .../paimon/sink/v2/PaimonWriterTest.java           | 218 ++++++++++
 .../sink/v2/bucket/PaimonPostponeBucketTest.java   |   7 +-
 .../values/source/ValuesDataSourceHelper.java      |  70 ++-
 17 files changed, 1940 insertions(+), 59 deletions(-)

diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/pom.xml
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/pom.xml
index b2e1c5de7..2ee68881a 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/pom.xml
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/pom.xml
@@ -51,6 +51,13 @@ limitations under the License.
             <scope>test</scope>
         </dependency>
 
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-cdc-pipeline-connector-values</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.flink</groupId>
             <artifactId>flink-clients</artifactId>
@@ -159,6 +166,27 @@ limitations under the License.
             </exclusions>
         </dependency>
 
+        <dependency>
+            <groupId>org.apache.hadoop</groupId>
+            <artifactId>hadoop-mapreduce-client-core</artifactId>
+            <version>${hadoop.version}</version>
+            <scope>test</scope>
+            <exclusions>
+                <exclusion>
+                    <groupId>log4j</groupId>
+                    <artifactId>log4j</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-log4j12</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.google.protobuf</groupId>
+                    <artifactId>protobuf-java</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+
         <!-- hive dependency -->
 
         <dependency>
@@ -198,6 +226,10 @@ limitations under the License.
                     <groupId>com.google.protobuf</groupId>
                     <artifactId>protobuf-java</artifactId>
                 </exclusion>
+                <exclusion>
+                    <groupId>org.apache.hbase</groupId>
+                    <artifactId>hbase-client</artifactId>
+                </exclusion>
             </exclusions>
         </dependency>
 
@@ -253,6 +285,18 @@ limitations under the License.
                 </exclusion>
             </exclusions>
         </dependency>
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-test-utils</artifactId>
+            <version>${flink.version}</version>
+            <scope>test</scope>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.testcontainers</groupId>
+                    <artifactId>testcontainers</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
     </dependencies>
 
     <build>
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonDataSinkFactory.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonDataSinkFactory.java
index a165946d4..faed8ed14 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonDataSinkFactory.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonDataSinkFactory.java
@@ -96,7 +96,7 @@ public class PaimonDataSinkFactory implements DataSinkFactory 
{
                 }
             }
         }
-        PaimonRecordSerializer<Event> serializer = new 
PaimonRecordEventSerializer(zoneId);
+        PaimonRecordSerializer<Event> serializer = new 
PaimonRecordEventSerializer(zoneId, options);
         String schemaOperatorUid =
                 context.getPipelineConfiguration()
                         .get(PipelineOptions.PIPELINE_SCHEMA_OPERATOR_UID);
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplier.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplier.java
index 99ae583f0..b7e35db1f 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplier.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplier.java
@@ -30,19 +30,24 @@ import org.apache.flink.cdc.common.event.TableId;
 import org.apache.flink.cdc.common.event.TruncateTableEvent;
 import org.apache.flink.cdc.common.event.visitor.SchemaChangeEventVisitor;
 import org.apache.flink.cdc.common.exceptions.SchemaEvolveException;
+import org.apache.flink.cdc.common.schema.Column;
 import org.apache.flink.cdc.common.schema.Schema;
 import org.apache.flink.cdc.common.sink.MetadataApplier;
+import org.apache.flink.cdc.common.types.DataType;
 import org.apache.flink.cdc.connectors.paimon.sink.utils.TypeUtils;
 
 import org.apache.flink.shaded.guava31.com.google.common.collect.Sets;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.flink.FlinkCatalogFactory;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
 import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.types.DataTypes;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -52,6 +57,8 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import static org.apache.flink.cdc.common.types.DataTypeFamily.BINARY_STRING;
+import static 
org.apache.flink.cdc.common.types.DataTypeFamily.CHARACTER_STRING;
 import static org.apache.flink.cdc.common.utils.Preconditions.checkArgument;
 import static org.apache.flink.cdc.common.utils.Preconditions.checkNotNull;
 
@@ -147,13 +154,16 @@ public class PaimonMetadataApplier implements 
MetadataApplier {
             Schema schema = event.getSchema();
             org.apache.paimon.schema.Schema.Builder builder =
                     new org.apache.paimon.schema.Schema.Builder();
+            Map<String, String> fullTableOptions = new HashMap<>(tableOptions);
+            fullTableOptions.putAll(schema.options());
+            Map<String, String> effectiveTableOptions = 
filterBlobOptions(schema, fullTableOptions);
             schema.getColumns()
                     .forEach(
-                            (column) ->
-                                    builder.column(
-                                            column.getName(),
-                                            
TypeUtils.toPaimonDataType(column.getType()),
-                                            column.getComment()));
+                            (column) -> {
+                                org.apache.paimon.types.DataType dataType =
+                                        convertToBlobIfNeeded(column, 
effectiveTableOptions);
+                                builder.column(column.getName(), dataType, 
column.getComment());
+                            });
             List<String> partitionKeys = new ArrayList<>();
             List<String> primaryKeys = schema.primaryKeys();
             if (partitionMaps.containsKey(event.tableId())) {
@@ -169,8 +179,7 @@ public class PaimonMetadataApplier implements 
MetadataApplier {
             builder.partitionKeys(partitionKeys)
                     .primaryKey(primaryKeys)
                     .comment(schema.comment())
-                    .options(tableOptions)
-                    .options(schema.options());
+                    .options(effectiveTableOptions);
             catalog.createTable(tableIdToIdentifier(event), builder.build(), 
true);
         } catch (Catalog.TableAlreadyExistException
                 | Catalog.DatabaseNotExistException
@@ -205,10 +214,12 @@ public class PaimonMetadataApplier implements 
MetadataApplier {
                                 SchemaChangeProvider.add(
                                         columnWithPosition,
                                         SchemaChange.Move.first(
-                                                
columnWithPosition.getAddColumn().getName())));
+                                                
columnWithPosition.getAddColumn().getName()),
+                                        tableOptions));
                         break;
                     case LAST:
-                        
tableChangeList.addAll(SchemaChangeProvider.add(columnWithPosition));
+                        tableChangeList.addAll(
+                                SchemaChangeProvider.add(columnWithPosition, 
tableOptions));
                         break;
                     case BEFORE:
                         tableChangeList.addAll(
@@ -225,7 +236,8 @@ public class PaimonMetadataApplier implements 
MetadataApplier {
                                 SchemaChange.Move.after(
                                         
columnWithPosition.getAddColumn().getName(),
                                         
columnWithPosition.getExistedColumnName());
-                        
tableChangeList.addAll(SchemaChangeProvider.add(columnWithPosition, after));
+                        tableChangeList.addAll(
+                                SchemaChangeProvider.add(columnWithPosition, 
after, tableOptions));
                         break;
                     default:
                         throw new SchemaEvolveException(
@@ -253,7 +265,8 @@ public class PaimonMetadataApplier implements 
MetadataApplier {
                 columnWithPosition,
                 (index == 0)
                         ? SchemaChange.Move.first(columnName)
-                        : SchemaChange.Move.after(columnName, 
columnNames.get(index - 1)));
+                        : SchemaChange.Move.after(columnName, 
columnNames.get(index - 1)),
+                tableOptions);
     }
 
     private int checkColumnPosition(String existedColumnName, List<String> 
columnNames) {
@@ -303,13 +316,22 @@ public class PaimonMetadataApplier implements 
MetadataApplier {
 
     private void applyAlterColumnType(AlterColumnTypeEvent event) throws 
SchemaEvolveException {
         try {
+            FileStoreTable table =
+                    (FileStoreTable)
+                            catalog.getTable(
+                                    new Identifier(
+                                            event.tableId().getSchemaName(),
+                                            event.tableId().getTableName()));
             List<SchemaChange> tableChangeList = new ArrayList<>();
             event.getTypeMapping()
                     .forEach(
-                            (oldName, newType) ->
-                                    tableChangeList.add(
-                                            
SchemaChangeProvider.updateColumnType(
-                                                    oldName, newType)));
+                            (columnName, newType) -> {
+                                // Modifying the primary key data type may 
lead to exceptions in
+                                // read/write/merge operations.
+                                SchemaChangeProvider.updateColumnType(
+                                                table.schema(), columnName, 
newType, tableOptions)
+                                        .ifPresent(tableChangeList::add);
+                            });
             event.getComments()
                     .forEach(
                             (name, comment) -> {
@@ -359,4 +381,63 @@ public class PaimonMetadataApplier implements 
MetadataApplier {
     private static Identifier tableIdToIdentifier(SchemaChangeEvent event) {
         return new Identifier(event.tableId().getSchemaName(), 
event.tableId().getTableName());
     }
+
+    /**
+     * Convert CDC VARBINARY/BINARY/CHAR/VARCHAR/STRING type to Paimon BLOB 
type if configured.
+     *
+     * @param column The CDC column definition.
+     * @param tableOptions The table options containing blob-field 
configuration.
+     * @return The Paimon DataType (BLOB if configured, otherwise original 
converted type).
+     */
+    private org.apache.paimon.types.DataType convertToBlobIfNeeded(
+            Column column, Map<String, String> tableOptions) {
+        org.apache.paimon.types.DataType dataType = 
TypeUtils.toPaimonDataType(column.getType());
+
+        // Check if this field should be converted to BLOB type using Paimon's 
CoreOptions
+        List<String> blobFields = CoreOptions.blobField(tableOptions);
+        if (!blobFields.isEmpty() && isSupportedTypeForBlob(column.getType())) 
{
+            if (blobFields.contains(column.getName())) {
+                // Convert VARBINARY/BINARY/VARCHAR/STRING to BLOB type
+                // BLOB type is always nullable in Paimon
+                return DataTypes.BLOB();
+            }
+        }
+
+        return dataType;
+    }
+
+    private Map<String, String> filterBlobOptions(
+            Schema schema, Map<String, String> fullTableOptions) {
+        Map<String, String> effectiveTableOptions = new 
HashMap<>(fullTableOptions);
+
+        List<String> blobFields = new 
ArrayList<>(CoreOptions.blobField(fullTableOptions));
+        blobFields.removeIf(
+                field ->
+                        !schema.getColumn(field)
+                                .map(Column::getType)
+                                .map(this::isSupportedTypeForBlob)
+                                .orElse(false));
+        if (blobFields.isEmpty()) {
+            effectiveTableOptions.remove(CoreOptions.BLOB_FIELD.key());
+        } else {
+            effectiveTableOptions.put(CoreOptions.BLOB_FIELD.key(), 
String.join(",", blobFields));
+        }
+
+        List<String> blobDescriptorFields =
+                new 
ArrayList<>(CoreOptions.fromMap(fullTableOptions).blobDescriptorField());
+        blobDescriptorFields.retainAll(blobFields);
+        if (blobDescriptorFields.isEmpty()) {
+            
effectiveTableOptions.remove(CoreOptions.BLOB_DESCRIPTOR_FIELD.key());
+        } else {
+            effectiveTableOptions.put(
+                    CoreOptions.BLOB_DESCRIPTOR_FIELD.key(),
+                    String.join(",", blobDescriptorFields));
+        }
+        return effectiveTableOptions;
+    }
+
+    /** Check if DataType can be converted to BLOB (BINARY, VARBINARY, CHAR or 
VARCHAR). */
+    private boolean isSupportedTypeForBlob(DataType dataType) {
+        return dataType.isAnyOf(BINARY_STRING, CHARACTER_STRING);
+    }
 }
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/SchemaChangeProvider.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/SchemaChangeProvider.java
index 5e8beacc9..b776c9a01 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/SchemaChangeProvider.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/SchemaChangeProvider.java
@@ -20,14 +20,19 @@ package org.apache.flink.cdc.connectors.paimon.sink;
 import org.apache.flink.cdc.common.event.AddColumnEvent;
 import org.apache.flink.cdc.common.schema.Column;
 import org.apache.flink.cdc.common.types.DataType;
+import org.apache.flink.cdc.common.types.DataTypeRoot;
 import org.apache.flink.cdc.common.types.LocalZonedTimestampType;
 import org.apache.flink.cdc.common.types.TimestampType;
 import org.apache.flink.cdc.common.types.ZonedTimestampType;
 import org.apache.flink.cdc.connectors.paimon.sink.utils.TypeUtils;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.types.DataTypes;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -49,15 +54,28 @@ public class SchemaChangeProvider {
      * @return A SchemaChange object representing the addition of a column.
      */
     public static List<SchemaChange> add(AddColumnEvent.ColumnWithPosition 
columnWithPosition) {
+        return add(columnWithPosition, Collections.emptyMap());
+    }
+
+    /**
+     * Creates a SchemaChange object for adding a column without specifying 
its position. Supports
+     * converting VARBINARY/BINARY/VARCHAR/STRING to BLOB type based on 
tableOptions configuration.
+     *
+     * @param columnWithPosition The ColumnWithPosition object containing the 
column details.
+     * @param tableOptions The table options containing blob-field 
configuration.
+     * @return A SchemaChange object representing the addition of a column.
+     */
+    public static List<SchemaChange> add(
+            AddColumnEvent.ColumnWithPosition columnWithPosition,
+            Map<String, String> tableOptions) {
         List<SchemaChange> result = new ArrayList<>();
-        result.add(
-                SchemaChange.addColumn(
-                        columnWithPosition.getAddColumn().getName(),
-                        
TypeUtils.toPaimonDataType(columnWithPosition.getAddColumn().getType()),
-                        columnWithPosition.getAddColumn().getComment()));
-        // if default value express exists, we need to set the default value 
to the table
-        // option
         Column column = columnWithPosition.getAddColumn();
+
+        org.apache.paimon.types.DataType dataType = 
convertToBlobIfNeeded(column, tableOptions);
+
+        result.add(SchemaChange.addColumn(column.getName(), dataType, 
column.getComment()));
+
+        // if default value express exists, we need to set the default value 
to the table option
         Optional.ofNullable(
                         convertInvalidTimestampDefaultValue(
                                 column.getDefaultValueExpression(), 
column.getType()))
@@ -81,16 +99,36 @@ public class SchemaChangeProvider {
      */
     public static List<SchemaChange> add(
             AddColumnEvent.ColumnWithPosition columnWithPosition, 
SchemaChange.Move move) {
+        return add(columnWithPosition, move, Collections.emptyMap());
+    }
+
+    /**
+     * Creates a SchemaChange object for adding a column with a specified 
position. Supports
+     * converting VARBINARY/BINARY/VARCHAR/STRING to BLOB type based on 
tableOptions configuration.
+     *
+     * @param columnWithPosition The ColumnWithPosition object containing the 
column details.
+     * @param move The move operation to indicate the column's new position.
+     * @param tableOptions The table options containing blob-field 
configuration.
+     * @return A SchemaChange object representing the addition of a column 
with position
+     *     information.
+     */
+    public static List<SchemaChange> add(
+            AddColumnEvent.ColumnWithPosition columnWithPosition,
+            SchemaChange.Move move,
+            Map<String, String> tableOptions) {
         List<SchemaChange> result = new ArrayList<>();
+        Column column = columnWithPosition.getAddColumn();
+
+        org.apache.paimon.types.DataType dataType = 
convertToBlobIfNeeded(column, tableOptions);
+
         result.add(
                 SchemaChange.addColumn(
                         columnWithPosition.getAddColumn().getName(),
-                        
TypeUtils.toPaimonDataType(columnWithPosition.getAddColumn().getType()),
+                        dataType,
                         columnWithPosition.getAddColumn().getComment(),
                         move));
         // if default value express exists, we need to set the default value 
to the table
         // option
-        Column column = columnWithPosition.getAddColumn();
         Optional.ofNullable(
                         convertInvalidTimestampDefaultValue(
                                 column.getDefaultValueExpression(), 
column.getType()))
@@ -103,6 +141,38 @@ public class SchemaChangeProvider {
         return result;
     }
 
+    /**
+     * Convert CDC VARBINARY/BINARY/VARCHAR/STRING type to Paimon BLOB type if 
configured.
+     *
+     * @param column The CDC column definition.
+     * @param tableOptions The table options containing blob-field 
configuration.
+     * @return The Paimon DataType (BLOB if configured, otherwise original 
converted type).
+     */
+    public static org.apache.paimon.types.DataType convertToBlobIfNeeded(
+            Column column, Map<String, String> tableOptions) {
+        // Check if this field should be converted to BLOB type using Paimon's 
CoreOptions
+        List<String> blobFields = CoreOptions.blobField(tableOptions);
+        if (!blobFields.isEmpty() && isSupportedTypeForBlob(column.getType())) 
{
+            if (blobFields.contains(column.getName())) {
+                // Convert VARBINARY/BINARY/VARCHAR/STRING to BLOB type
+                // BLOB type is always nullable in Paimon
+                return DataTypes.BLOB();
+            }
+        }
+
+        // Use TypeUtils.toPaimonDataType which handles VARIANT type properly
+        return TypeUtils.toPaimonDataType(column.getType());
+    }
+
+    /** Check if DataType can be converted to BLOB (BINARY, VARBINARY, CHAR or 
VARCHAR). */
+    private static boolean isSupportedTypeForBlob(DataType dataType) {
+        DataTypeRoot typeRoot = dataType.getTypeRoot();
+        return typeRoot == DataTypeRoot.BINARY
+                || typeRoot == DataTypeRoot.VARBINARY
+                || typeRoot == DataTypeRoot.CHAR
+                || typeRoot == DataTypeRoot.VARCHAR;
+    }
+
     /**
      * Creates a SchemaChange object to update the data type of a column.
      *
@@ -110,8 +180,64 @@ public class SchemaChangeProvider {
      * @param newType The new DataType for the column.
      * @return A SchemaChange object representing the update of the column's 
data type.
      */
-    public static SchemaChange updateColumnType(String oldColumnName, DataType 
newType) {
-        return SchemaChange.updateColumnType(oldColumnName, 
TypeUtils.toPaimonDataType(newType));
+    public static Optional<SchemaChange> updateColumnType(
+            TableSchema tableSchema, String oldColumnName, DataType newType) {
+        return updateColumnType(tableSchema, oldColumnName, newType, 
Collections.emptyMap());
+    }
+
+    /**
+     * Creates a SchemaChange object to update the data type of a column. 
Supports converting
+     * VARBINARY/BINARY/VARCHAR/STRING to BLOB type based on tableOptions 
configuration.
+     *
+     * @param tableSchema The TableSchema object containing the current schema 
of the table.
+     * @param oldColumnName The name of the column whose data type is to be 
updated.
+     * @param newType The new DataType for the column.
+     * @param tableOptions The table options containing blob-field 
configuration.
+     * @return An Optional of the SchemaChange that represents the update of 
the column's data type.
+     */
+    public static Optional<SchemaChange> updateColumnType(
+            TableSchema tableSchema,
+            String oldColumnName,
+            DataType newType,
+            Map<String, String> tableOptions) {
+        org.apache.paimon.types.DataType oldDataType =
+                tableSchema.logicalRowType().getField(oldColumnName).type();
+
+        org.apache.paimon.types.DataType newDataType;
+        // Handle BLOB type conversion using Paimon's CoreOptions
+        if (isSupportedTypeForBlob(newType)) {
+            List<String> blobFields = CoreOptions.blobField(tableOptions);
+            if (blobFields.contains(oldColumnName)) {
+                // BLOB type is always nullable in Paimon
+                newDataType = DataTypes.BLOB();
+            } else {
+                // Use TypeUtils.toPaimonDataType which handles VARIANT type 
properly
+                newDataType = TypeUtils.toPaimonDataType(newType);
+            }
+        } else {
+            // Use TypeUtils.toPaimonDataType which handles VARIANT type 
properly
+            newDataType = TypeUtils.toPaimonDataType(newType);
+        }
+
+        if (oldDataType.equalsIgnoreNullable(newDataType)) {
+            // Updating a column's nullability from notnull to nullable is not 
allowed.
+            return !oldDataType.isNullable() && newDataType.isNullable()
+                    ? 
Optional.of(SchemaChange.updateColumnNullability(oldColumnName, true))
+                    : Optional.empty();
+        } else {
+            if (tableSchema.primaryKeys().contains(oldColumnName)
+                    || tableSchema.partitionKeys().contains(oldColumnName)) {
+                throw new UnsupportedOperationException(
+                        String.format(
+                                "Altering column type of a primary 
key/partition key column for %s from %s to %s is not supported.\n"
+                                        + "If you need to change the primary 
key/partition key type, it is recommended to recreate the table and reimport 
the data.",
+                                oldColumnName, oldDataType, newType));
+            }
+            return Optional.of(
+                    SchemaChange.updateColumnType(
+                            oldColumnName,
+                            newDataType.copy(oldDataType.isNullable() || 
newType.isNullable())));
+        }
     }
 
     /**
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonRecordEventSerializer.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonRecordEventSerializer.java
index 52c9018ff..f1b8a9450 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonRecordEventSerializer.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonRecordEventSerializer.java
@@ -25,10 +25,15 @@ import org.apache.flink.cdc.common.event.SchemaChangeEvent;
 import org.apache.flink.cdc.common.event.TableId;
 import org.apache.flink.cdc.common.schema.Schema;
 import org.apache.flink.cdc.common.utils.SchemaUtils;
+import org.apache.flink.cdc.connectors.paimon.sink.v2.blob.BlobWriteContext;
 import 
org.apache.flink.cdc.connectors.paimon.sink.v2.bucket.BucketWrapperChangeEvent;
 
+import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.flink.FlinkCatalogFactory;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.table.FileStoreTable;
 
 import java.time.ZoneId;
 import java.util.HashMap;
@@ -47,13 +52,19 @@ public class PaimonRecordEventSerializer implements 
PaimonRecordSerializer<Event
     // ZoneId for converting relevant type.
     private final ZoneId zoneId;
 
-    public PaimonRecordEventSerializer(ZoneId zoneId) {
+    private final Options options;
+
+    private transient Catalog catalog;
+
+    public PaimonRecordEventSerializer(ZoneId zoneId, Options options) {
         schemaMaps = new HashMap<>();
         this.zoneId = zoneId;
+        this.options = options;
     }
 
     @Override
     public PaimonEvent serialize(Event event) {
+        lazilyInitializeCatalog();
         int bucket = 0;
         if (event instanceof BucketWrapperChangeEvent) {
             bucket = ((BucketWrapperChangeEvent) event).getBucket();
@@ -62,19 +73,30 @@ public class PaimonRecordEventSerializer implements 
PaimonRecordSerializer<Event
         Identifier tableId = Identifier.fromString(((ChangeEvent) 
event).tableId().toString());
         if (event instanceof SchemaChangeEvent) {
             if (event instanceof CreateTableEvent) {
-                CreateTableEvent createTableEvent = (CreateTableEvent) event;
-                schemaMaps.put(
-                        createTableEvent.tableId(),
-                        new TableSchemaInfo(createTableEvent.getSchema(), 
zoneId));
+                try {
+                    FileStoreTable table = (FileStoreTable) 
catalog.getTable(tableId);
+                    BlobWriteContext blobWriteContext =
+                            
BlobWriteContext.fromTable(catalog.caseSensitive(), table);
+                    CreateTableEvent createTableEvent = (CreateTableEvent) 
event;
+                    schemaMaps.put(
+                            createTableEvent.tableId(),
+                            new TableSchemaInfo(
+                                    createTableEvent.getSchema(), zoneId, 
blobWriteContext));
+                } catch (Catalog.TableNotExistException e) {
+                    throw new IllegalStateException(e);
+                }
             } else {
                 SchemaChangeEvent schemaChangeEvent = (SchemaChangeEvent) 
event;
                 Schema schema = 
schemaMaps.get(schemaChangeEvent.tableId()).getSchema();
                 if (!SchemaUtils.isSchemaChangeEventRedundant(schema, 
schemaChangeEvent)) {
+                    Schema newSchema =
+                            SchemaUtils.applySchemaChangeEvent(schema, 
schemaChangeEvent);
+                    // Preserve BlobWriteContext if it exists
+                    BlobWriteContext existingContext =
+                            
schemaMaps.get(schemaChangeEvent.tableId()).getBlobWriteContext();
                     schemaMaps.put(
                             schemaChangeEvent.tableId(),
-                            new TableSchemaInfo(
-                                    SchemaUtils.applySchemaChangeEvent(schema, 
schemaChangeEvent),
-                                    zoneId));
+                            new TableSchemaInfo(newSchema, zoneId, 
existingContext));
                 }
             }
             return new PaimonEvent(tableId, null, true);
@@ -92,4 +114,10 @@ public class PaimonRecordEventSerializer implements 
PaimonRecordSerializer<Event
                     "failed to convert Input into PaimonEvent, unsupported 
event: " + event);
         }
     }
+
+    private void lazilyInitializeCatalog() {
+        if (this.catalog == null) {
+            this.catalog = FlinkCatalogFactory.createPaimonCatalog(options);
+        }
+    }
 }
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriter.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriter.java
index 2e1f9ea15..0bcab4d85 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriter.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriter.java
@@ -156,6 +156,7 @@ public class PaimonWriter<InputT>
         if (paimonEvent.getGenericRows() != null) {
             FileStoreTable table;
             table = getTable(tableId);
+
             if (memoryPoolFactory == null) {
                 memoryPoolFactory =
                         new MemoryPoolFactory(
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriterHelper.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriterHelper.java
index abed564fe..5e393fa6d 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriterHelper.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriterHelper.java
@@ -31,12 +31,15 @@ import org.apache.flink.cdc.common.schema.Schema;
 import org.apache.flink.cdc.common.types.DataType;
 import org.apache.flink.cdc.common.types.DataTypeChecks;
 import org.apache.flink.cdc.common.types.DataTypeRoot;
+import org.apache.flink.cdc.common.types.DataTypes;
 import org.apache.flink.cdc.common.types.variant.BinaryVariant;
 import org.apache.flink.cdc.common.utils.Preconditions;
 import org.apache.flink.cdc.connectors.paimon.sink.utils.TypeUtils;
+import org.apache.flink.cdc.connectors.paimon.sink.v2.blob.BlobWriteContext;
 import 
org.apache.flink.cdc.connectors.paimon.sink.v2.bucket.BucketAssignOperator;
 import org.apache.flink.core.memory.MemorySegment;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.BinaryString;
@@ -50,10 +53,13 @@ import org.apache.paimon.table.Table;
 import org.apache.paimon.types.RowKind;
 import org.apache.paimon.types.RowType;
 
+import javax.annotation.Nullable;
+
 import java.nio.ByteBuffer;
 import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 import static org.apache.flink.cdc.common.types.DataTypeChecks.getFieldCount;
@@ -66,10 +72,26 @@ public class PaimonWriterHelper {
 
     /** create a list of {@link RecordData.FieldGetter} for {@link 
PaimonWriter}. */
     public static List<RecordData.FieldGetter> createFieldGetters(Schema 
schema, ZoneId zoneId) {
+        return createFieldGetters(schema, zoneId, null);
+    }
+
+    /**
+     * create a list of {@link RecordData.FieldGetter} for {@link 
PaimonWriter} with BLOB support.
+     *
+     * @param schema The CDC schema containing column definitions.
+     * @param zoneId The timezone for timestamp conversion.
+     * @param blobWriteContext Optional context for BLOB field handling.
+     * @return List of FieldGetter for converting CDC data to Paimon data.
+     */
+    public static List<RecordData.FieldGetter> createFieldGetters(
+            Schema schema, ZoneId zoneId, @Nullable BlobWriteContext 
blobWriteContext) {
         List<Column> columns = schema.getColumns();
         List<RecordData.FieldGetter> fieldGetters = new 
ArrayList<>(columns.size());
         for (int i = 0; i < columns.size(); i++) {
-            fieldGetters.add(createFieldGetter(columns.get(i).getType(), i, 
zoneId));
+            Column column = columns.get(i);
+            fieldGetters.add(
+                    createFieldGetter(
+                            column.getType(), i, zoneId, blobWriteContext, 
column.getName()));
         }
         return fieldGetters;
     }
@@ -101,19 +123,64 @@ public class PaimonWriterHelper {
 
     private static RecordData.FieldGetter createFieldGetter(
             DataType fieldType, int fieldPos, ZoneId zoneId) {
+        return createFieldGetter(fieldType, fieldPos, zoneId, null, null);
+    }
+
+    private static RecordData.FieldGetter createFieldGetter(
+            DataType fieldType,
+            int fieldPos,
+            ZoneId zoneId,
+            @Nullable BlobWriteContext blobWriteContext,
+            @Nullable String fieldName) {
         final RecordData.FieldGetter fieldGetter;
         // ordered by type root definition
         switch (fieldType.getTypeRoot()) {
             case CHAR:
             case VARCHAR:
-                fieldGetter = row -> 
BinaryString.fromString(row.getString(fieldPos).toString());
+                // Check if this field should be converted to BLOB type
+                if (blobWriteContext != null
+                        && fieldName != null
+                        && blobWriteContext.isBlobField(fieldName)) {
+                    if (blobWriteContext.isBlobDescriptorField(fieldName)) {
+                        // blob-descriptor-field mode: Create BlobRef that 
stores descriptor info.
+                        // Only descriptor (uri, offset, length) is stored 
inline, external data is
+                        // NOT read or copied. Actual data reading happens on 
Paimon read side.
+                        fieldGetter =
+                                row -> {
+                                    String path = 
row.getString(fieldPos).toString();
+                                    return 
blobWriteContext.createBlobRef(path);
+                                };
+                    } else {
+                        throw new IllegalArgumentException(
+                                String.format(
+                                        "VARCHAR/CHAR blob field '%s' must be 
configured in 'blob-descriptor-field'. "
+                                                + "For VARCHAR/CHAR fields 
configured in 'blob-field', you must also configure "
+                                                + "'blob-descriptor-field' to 
specify which fields store serialized BlobDescriptor bytes inline. "
+                                                + "If you want to write raw 
blob data from string path, please use VARBINARY/BINARY type instead.",
+                                        fieldName));
+                    }
+                } else {
+                    fieldGetter =
+                            row -> 
BinaryString.fromString(row.getString(fieldPos).toString());
+                }
                 break;
             case BOOLEAN:
                 fieldGetter = row -> row.getBoolean(fieldPos);
                 break;
             case BINARY:
             case VARBINARY:
-                fieldGetter = row -> row.getBinary(fieldPos);
+                // Check if this field should be converted to BLOB type
+                if (blobWriteContext != null
+                        && fieldName != null
+                        && blobWriteContext.isBlobField(fieldName)) {
+                    fieldGetter =
+                            row -> {
+                                byte[] bytes = row.getBinary(fieldPos);
+                                return blobWriteContext.createBlob(bytes);
+                            };
+                } else {
+                    fieldGetter = row -> row.getBinary(fieldPos);
+                }
                 break;
             case DECIMAL:
                 final int decimalPrecision = 
DataTypeChecks.getPrecision(fieldType);
@@ -296,11 +363,45 @@ public class PaimonWriterHelper {
         builder.setColumns(
                 rowType.getFields().stream()
                         .map(
-                                column ->
-                                        Column.physicalColumn(
-                                                column.name(),
-                                                
TypeUtils.toCDCDataType(column.type()),
-                                                column.description()))
+                                column -> {
+                                    org.apache.flink.cdc.common.types.DataType 
cdcType;
+                                    // Handle BLOB type: Paimon BLOB → CDC 
VARBINARY or STRING
+                                    //
+                                    // Paimon supports two blob storage modes:
+                                    // 1. Raw data mode: VARBINARY/BINARY → 
BlobData → written to
+                                    // .blob files
+                                    // 2. Descriptor mode: STRING → BlobRef → 
only descriptor (uri,
+                                    // offset, length)
+                                    //    stored inline, actual data remains 
in external storage
+                                    //
+                                    // For descriptor mode fields (configured 
via
+                                    // blob-descriptor-field option),
+                                    // the upstream provides URI/path strings, 
so CDC type should be
+                                    // STRING.
+                                    // For raw data mode fields, the upstream 
provides raw bytes, so
+                                    // CDC type
+                                    // should be VARBINARY.
+                                    if (column.type().getTypeRoot()
+                                            == 
org.apache.paimon.types.DataTypeRoot.BLOB) {
+                                        CoreOptions coreOptions =
+                                                
CoreOptions.fromMap(table.options());
+                                        Set<String> blobDescriptorFields =
+                                                
coreOptions.blobDescriptorField();
+                                        if 
(blobDescriptorFields.contains(column.name())) {
+                                            // Descriptor mode: upstream 
provides URI string
+                                            cdcType = DataTypes.STRING();
+                                        } else {
+                                            // Raw data mode: upstream 
provides raw bytes
+                                            cdcType = 
DataTypes.VARBINARY(Integer.MAX_VALUE);
+                                        }
+                                    } else {
+                                        // Use TypeUtils.toCDCDataType which 
handles VARIANT type
+                                        // properly
+                                        cdcType = 
TypeUtils.toCDCDataType(column.type());
+                                    }
+                                    return Column.physicalColumn(
+                                            column.name(), cdcType, 
column.description());
+                                })
                         .collect(Collectors.toList()));
         builder.primaryKey(table.primaryKeys());
         table.comment().ifPresent(builder::comment);
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/TableSchemaInfo.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/TableSchemaInfo.java
index ed46baa7a..ccea3ca21 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/TableSchemaInfo.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/TableSchemaInfo.java
@@ -19,6 +19,9 @@ package org.apache.flink.cdc.connectors.paimon.sink.v2;
 
 import org.apache.flink.cdc.common.data.RecordData;
 import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.connectors.paimon.sink.v2.blob.BlobWriteContext;
+
+import javax.annotation.Nullable;
 
 import java.time.ZoneId;
 import java.util.List;
@@ -28,13 +31,17 @@ public class TableSchemaInfo {
 
     private final Schema schema;
 
-    private final List<RecordData.FieldGetter> fieldGetters;
+    private List<RecordData.FieldGetter> fieldGetters;
 
     private final boolean hasPrimaryKey;
 
-    public TableSchemaInfo(Schema schema, ZoneId zoneId) {
+    @Nullable private BlobWriteContext blobWriteContext;
+
+    public TableSchemaInfo(
+            Schema schema, ZoneId zoneId, @Nullable BlobWriteContext 
blobWriteContext) {
         this.schema = schema;
-        this.fieldGetters = PaimonWriterHelper.createFieldGetters(schema, 
zoneId);
+        this.blobWriteContext = blobWriteContext;
+        this.fieldGetters = PaimonWriterHelper.createFieldGetters(schema, 
zoneId, blobWriteContext);
         this.hasPrimaryKey = !schema.primaryKeys().isEmpty();
     }
 
@@ -49,4 +56,9 @@ public class TableSchemaInfo {
     public boolean hasPrimaryKey() {
         return hasPrimaryKey;
     }
+
+    @Nullable
+    public BlobWriteContext getBlobWriteContext() {
+        return blobWriteContext;
+    }
 }
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/blob/BlobWriteContext.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/blob/BlobWriteContext.java
new file mode 100644
index 000000000..4fbb99bc1
--- /dev/null
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/blob/BlobWriteContext.java
@@ -0,0 +1,145 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.cdc.connectors.paimon.sink.v2.blob;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.Blob;
+import org.apache.paimon.data.BlobData;
+import org.apache.paimon.data.BlobDescriptor;
+import org.apache.paimon.data.BlobRef;
+import org.apache.paimon.table.FileStoreTable;
+
+import javax.annotation.Nullable;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.apache.paimon.utils.StringUtils.toLowerCaseIfNeed;
+
+/**
+ * Context for handling BLOB fields during CDC write operations.
+ *
+ * <p>CDC connector only handles WRITE operations. We do NOT read external 
blob data. The actual
+ * blob data reading happens on Paimon read side.
+ *
+ * <p>Two blob storage modes for CDC write:
+ *
+ * <ul>
+ *   <li>Mode 1 (raw data): VARBINARY/BINARY fields → BlobData → written to 
.blob files by Paimon.
+ *   <li>Mode 2 (descriptor): VARCHAR/STRING fields → BlobRef → only 
descriptor (uri, offset,
+ *       length) stored inline. External data is NOT read or copied during 
write.
+ * </ul>
+ */
+public class BlobWriteContext implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private final boolean isCaseSensitive;
+
+    /** Fields that should be converted to BLOB type. */
+    private final Set<String> blobFields;
+
+    /** Fields configured with blob-descriptor-field (Mode 2). */
+    private final Set<String> blobDescriptorFields;
+
+    private BlobWriteContext(
+            boolean isCaseSensitive, Set<String> blobFields, Set<String> 
blobDescriptorFields) {
+        this.isCaseSensitive = isCaseSensitive;
+        // If case-insensitive, normalize field names to lowercase for 
consistent matching
+        this.blobFields = downcaseFieldNames(blobFields, isCaseSensitive);
+        this.blobDescriptorFields = downcaseFieldNames(blobDescriptorFields, 
isCaseSensitive);
+    }
+
+    /** Downcase field names if case-insensitive. */
+    private static Set<String> downcaseFieldNames(Set<String> fieldNames, 
boolean caseSensitive) {
+        if (caseSensitive) {
+            return fieldNames;
+        }
+        Set<String> normalized = new HashSet<>();
+        for (String fieldName : fieldNames) {
+            normalized.add(fieldName.toLowerCase());
+        }
+        return normalized;
+    }
+
+    /** Create BlobWriteContext from Paimon table's CoreOptions. */
+    public static BlobWriteContext fromTable(boolean isCaseSensitive, 
FileStoreTable table) {
+        List<String> blobFieldList = CoreOptions.blobField(table.options());
+        if (blobFieldList.isEmpty()) {
+            return empty();
+        }
+        CoreOptions coreOptions = CoreOptions.fromMap(table.options());
+        Set<String> blobDescriptorFields = coreOptions.blobDescriptorField();
+        return new BlobWriteContext(
+                isCaseSensitive, new HashSet<>(blobFieldList), 
blobDescriptorFields);
+    }
+
+    /** Create an empty BlobWriteContext with no blob fields. */
+    public static BlobWriteContext empty() {
+        return new BlobWriteContext(true, Collections.emptySet(), 
Collections.emptySet());
+    }
+
+    /** Check if a field should be converted to BLOB type. */
+    public boolean isBlobField(String fieldName) {
+        return blobFields.contains(toLowerCaseIfNeed(fieldName, 
isCaseSensitive));
+    }
+
+    /** Get the set of blob field names. */
+    public Set<String> getBlobFields() {
+        return blobFields;
+    }
+
+    /** Check if a field is configured with blob-descriptor-field. */
+    public boolean isBlobDescriptorField(String fieldName) {
+        return blobDescriptorFields.contains(toLowerCaseIfNeed(fieldName, 
isCaseSensitive));
+    }
+
+    /** Get the set of blob descriptor field names. */
+    public Set<String> getBlobDescriptorFields() {
+        return blobDescriptorFields;
+    }
+
+    /** Create BlobData from raw bytes (Mode 1). */
+    @Nullable
+    public Blob createBlob(@Nullable byte[] bytes) {
+        if (bytes == null) {
+            return null;
+        }
+        return new BlobData(bytes);
+    }
+
+    /** Create BlobRef from a path string (Mode 2). */
+    @Nullable
+    public Blob createBlobRef(@Nullable String path) {
+        return createBlobRef(path, -1);
+    }
+
+    /** Create BlobRef from a path string with specified length (Mode 2). */
+    @Nullable
+    public Blob createBlobRef(@Nullable String path, long length) {
+        if (path == null) {
+            return null;
+        }
+        BlobDescriptor descriptor = new BlobDescriptor(path, 0, length);
+        // UriReader is null - CDC connector only writes descriptor info, not 
reading data
+        return new BlobRef(null, descriptor);
+    }
+}
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/bucket/BucketAssignOperator.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/bucket/BucketAssignOperator.java
index e3c6f095b..cc4a182d2 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/bucket/BucketAssignOperator.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/v2/bucket/BucketAssignOperator.java
@@ -251,8 +251,8 @@ public class BucketAssignOperator extends 
AbstractStreamOperatorAdapter<Event>
         MixedSchemaInfo mixedSchemaInfo =
                 new MixedSchemaInfo(
                         tableId,
-                        new TableSchemaInfo(upstreamSchema, zoneId),
-                        new TableSchemaInfo(physicalSchema, zoneId));
+                        new TableSchemaInfo(upstreamSchema, zoneId, null),
+                        new TableSchemaInfo(physicalSchema, zoneId, null));
         if (!mixedSchemaInfo.isSameColumnsIgnoringCommentAndDefaultValue()) {
             LOGGER.warn(
                     "Upstream schema of {} is {}, which is different with 
paimon physical table schema {}. Data precision loss and truncation may occur.",
@@ -280,13 +280,14 @@ public class BucketAssignOperator extends 
AbstractStreamOperatorAdapter<Event>
                 MixedSchemaInfo mixedSchemaInfo =
                         new MixedSchemaInfo(
                                 tableId,
-                                new TableSchemaInfo(schema.get(), zoneId),
+                                new TableSchemaInfo(schema.get(), zoneId, 
null),
                                 new TableSchemaInfo(
                                         
PaimonWriterHelper.deduceSchemaForPaimonTable(
                                                 catalog.getTable(
                                                         
PaimonWriterHelper.identifierFromTableId(
                                                                 tableId))),
-                                        zoneId));
+                                        zoneId,
+                                        null));
                 if 
(!mixedSchemaInfo.isSameColumnsIgnoringCommentAndDefaultValue()) {
                     LOGGER.warn(
                             "Upstream schema of {} is {}, which is different 
with paimon physical table schema {}. Data precision loss and truncation may 
occur.",
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplierTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplierTest.java
index 9d7e1829a..0ac4c6af4 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplierTest.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplierTest.java
@@ -28,6 +28,7 @@ import org.apache.flink.cdc.common.schema.Column;
 import org.apache.flink.cdc.common.sink.MetadataApplier;
 import org.apache.flink.cdc.common.types.DataType;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.flink.FlinkCatalogFactory;
@@ -52,6 +53,8 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.UUID;
 
+import static org.assertj.core.api.Assertions.assertThat;
+
 /** Tests for {@link PaimonMetadataApplier}. */
 class PaimonMetadataApplierTest {
 
@@ -723,4 +726,476 @@ class PaimonMetadataApplierTest {
 
         Assertions.assertThat(table).isNotNull();
     }
+
+    // ========== BLOB type support tests ==========
+    // NOTE: Paimon BLOB type has the following constraints that prevent 
integration tests:
+    // 1. BLOB type requires data-evolution.enabled = true
+    // 2. BLOB type requires row-tracking.enabled = true
+    // 3. row-tracking.enabled cannot be combined with primary keys
+    // This means BLOB columns can only exist in append-only tables (no 
primary keys).
+    // Since CDC pipeline typically works with primary key tables, actual BLOB 
type creation
+    // tests are disabled. The type conversion logic (VARBINARY/BINARY to 
BLOB) is correctly
+    // implemented in PaimonMetadataApplier.convertBinaryToBlobIfNeeded() and
+    // SchemaChangeProvider.convertBinaryToBlobIfNeeded() methods.
+
+    @ParameterizedTest
+    @ValueSource(strings = {"filesystem"})
+    void testCreateTableWithoutBlobFieldConfig(String metastore)
+            throws Catalog.TableNotExistException,
+                    Catalog.DatabaseNotEmptyException,
+                    Catalog.DatabaseNotExistException,
+                    SchemaEvolveException {
+        initialize(metastore);
+        MetadataApplier metadataApplier = new 
PaimonMetadataApplier(catalogOptions);
+
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(
+                        TableId.parse("test.no_blob_config_table"),
+                        org.apache.flink.cdc.common.schema.Schema.newBuilder()
+                                .physicalColumn(
+                                        "id",
+                                        
org.apache.flink.cdc.common.types.DataTypes.INT().notNull())
+                                .physicalColumn(
+                                        "varbinary_content",
+                                        
org.apache.flink.cdc.common.types.DataTypes.VARBINARY(100))
+                                .physicalColumn(
+                                        "binary_content",
+                                        
org.apache.flink.cdc.common.types.DataTypes.BINARY(10))
+                                .primaryKey("id")
+                                .build());
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Table table = 
catalog.getTable(Identifier.fromString("test.no_blob_config_table"));
+        RowType rowType = table.rowType();
+
+        assertThat(rowType.getField("varbinary_content").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.VARBINARY);
+        assertThat(rowType.getField("binary_content").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.BINARY);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"filesystem"})
+    void testAddNormalVarbinaryColumnWithoutBlobConfig(String metastore)
+            throws Catalog.TableNotExistException,
+                    Catalog.DatabaseNotEmptyException,
+                    Catalog.DatabaseNotExistException,
+                    SchemaEvolveException {
+        initialize(metastore);
+        Map<String, String> tableOptions = new HashMap<>();
+        MetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(
+                        TableId.parse("test.normal_varbinary_table"),
+                        org.apache.flink.cdc.common.schema.Schema.newBuilder()
+                                .physicalColumn(
+                                        "id",
+                                        
org.apache.flink.cdc.common.types.DataTypes.INT().notNull())
+                                .primaryKey("id")
+                                .build());
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        List<AddColumnEvent.ColumnWithPosition> addedColumns = new 
ArrayList<>();
+        addedColumns.add(
+                new AddColumnEvent.ColumnWithPosition(
+                        Column.physicalColumn(
+                                "varbinary_col",
+                                
org.apache.flink.cdc.common.types.DataTypes.VARBINARY(100))));
+        AddColumnEvent addColumnEvent =
+                new 
AddColumnEvent(TableId.parse("test.normal_varbinary_table"), addedColumns);
+        metadataApplier.applySchemaChange(addColumnEvent);
+
+        Table table = 
catalog.getTable(Identifier.fromString("test.normal_varbinary_table"));
+        RowType rowType = table.rowType();
+
+        assertThat(rowType.getField("varbinary_col").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.VARBINARY);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"filesystem"})
+    void testCreateTableWithEmptyBlobFieldConfig(String metastore)
+            throws Catalog.TableNotExistException,
+                    Catalog.DatabaseNotEmptyException,
+                    Catalog.DatabaseNotExistException,
+                    SchemaEvolveException {
+        initialize(metastore);
+        Map<String, String> tableOptions = new HashMap<>();
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), "");
+        MetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(
+                        TableId.parse("test.empty_blob_config_table"),
+                        org.apache.flink.cdc.common.schema.Schema.newBuilder()
+                                .physicalColumn(
+                                        "id",
+                                        
org.apache.flink.cdc.common.types.DataTypes.INT().notNull())
+                                .physicalColumn(
+                                        "content",
+                                        
org.apache.flink.cdc.common.types.DataTypes.VARBINARY(100))
+                                .primaryKey("id")
+                                .build());
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Table table = 
catalog.getTable(Identifier.fromString("test.empty_blob_config_table"));
+        RowType rowType = table.rowType();
+
+        assertThat(rowType.getField("content").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.VARBINARY);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"filesystem"})
+    void testCreateTableWithNonExistentBlobFieldConfig(String metastore)
+            throws Catalog.TableNotExistException,
+                    Catalog.DatabaseNotEmptyException,
+                    Catalog.DatabaseNotExistException,
+                    SchemaEvolveException {
+        initialize(metastore);
+        Map<String, String> tableOptions = new HashMap<>();
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), "nonexistent_blob_col");
+        MetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(
+                        TableId.parse("test.nonexistent_blob_config_table"),
+                        org.apache.flink.cdc.common.schema.Schema.newBuilder()
+                                .physicalColumn(
+                                        "id",
+                                        
org.apache.flink.cdc.common.types.DataTypes.INT().notNull())
+                                .physicalColumn(
+                                        "actual_varbinary",
+                                        
org.apache.flink.cdc.common.types.DataTypes.VARBINARY(100))
+                                .primaryKey("id")
+                                .build());
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Table table = 
catalog.getTable(Identifier.fromString("test.nonexistent_blob_config_table"));
+        RowType rowType = table.rowType();
+
+        assertThat(rowType.getField("actual_varbinary").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.VARBINARY);
+        assertThat(rowType.getField("id").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.INTEGER);
+    }
+
+    // ========== BLOB type integration tests with append-only tables (no 
primary keys) ==========
+    // These tests verify actual BLOB type creation in Paimon tables.
+    // BLOB type requires:
+    // 1. row-tracking.enabled = true
+    // 2. data-evolution.enabled = true
+    // These options cannot be combined with primary keys.
+
+    @ParameterizedTest
+    @ValueSource(strings = {"filesystem"})
+    void testCreateAppendOnlyTableWithBlobField(String metastore)
+            throws Catalog.TableNotExistException,
+                    Catalog.DatabaseNotEmptyException,
+                    Catalog.DatabaseNotExistException,
+                    SchemaEvolveException {
+        initialize(metastore);
+        Map<String, String> tableOptions = new HashMap<>();
+        // Configure blob-field to convert VARBINARY to BLOB
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), "content");
+        // Required for BLOB type
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        MetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        // Create append-only table (no primary keys)
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(
+                        TableId.parse("test.blob_append_only_table"),
+                        org.apache.flink.cdc.common.schema.Schema.newBuilder()
+                                .physicalColumn(
+                                        "id",
+                                        
org.apache.flink.cdc.common.types.DataTypes.INT().notNull())
+                                .physicalColumn(
+                                        "content",
+                                        
org.apache.flink.cdc.common.types.DataTypes.VARBINARY(100))
+                                // No primaryKey() - append-only table
+                                .build());
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Table table = 
catalog.getTable(Identifier.fromString("test.blob_append_only_table"));
+        RowType rowType = table.rowType();
+
+        // Verify content field is converted to BLOB type
+        assertThat(rowType.getField("content").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.BLOB);
+        assertThat(rowType.getField("id").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.INTEGER);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"filesystem"})
+    void testCreateAppendOnlyTableWithMultipleBlobFields(String metastore)
+            throws Catalog.TableNotExistException,
+                    Catalog.DatabaseNotEmptyException,
+                    Catalog.DatabaseNotExistException,
+                    SchemaEvolveException {
+        initialize(metastore);
+        Map<String, String> tableOptions = new HashMap<>();
+        // Configure multiple blob fields
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), 
"blob_content,image_data");
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        MetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        // Create append-only table with multiple VARBINARY columns
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(
+                        TableId.parse("test.multi_blob_append_only_table"),
+                        org.apache.flink.cdc.common.schema.Schema.newBuilder()
+                                .physicalColumn(
+                                        "id",
+                                        
org.apache.flink.cdc.common.types.DataTypes.INT().notNull())
+                                .physicalColumn(
+                                        "blob_content",
+                                        
org.apache.flink.cdc.common.types.DataTypes.VARBINARY(100))
+                                .physicalColumn(
+                                        "image_data",
+                                        
org.apache.flink.cdc.common.types.DataTypes.VARBINARY(1000))
+                                .physicalColumn(
+                                        "normal_varbinary",
+                                        
org.apache.flink.cdc.common.types.DataTypes.VARBINARY(50))
+                                // No primaryKey() - append-only table
+                                .build());
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Table table = 
catalog.getTable(Identifier.fromString("test.multi_blob_append_only_table"));
+        RowType rowType = table.rowType();
+
+        // Verify blob_content and image_data are converted to BLOB
+        assertThat(rowType.getField("blob_content").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.BLOB);
+        assertThat(rowType.getField("image_data").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.BLOB);
+        // normal_varbinary is not in blob-field config, should remain 
VARBINARY
+        assertThat(rowType.getField("normal_varbinary").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.VARBINARY);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"filesystem"})
+    void testAddBlobColumnToAppendOnlyTable(String metastore)
+            throws Catalog.TableNotExistException,
+                    Catalog.DatabaseNotEmptyException,
+                    Catalog.DatabaseNotExistException,
+                    SchemaEvolveException {
+        initialize(metastore);
+        Map<String, String> tableOptions = new HashMap<>();
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), 
"new_blob_col,char_blob_col");
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        MetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        // Create append-only table first
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(
+                        TableId.parse("test.add_blob_column_table"),
+                        org.apache.flink.cdc.common.schema.Schema.newBuilder()
+                                .physicalColumn(
+                                        "id",
+                                        
org.apache.flink.cdc.common.types.DataTypes.INT().notNull())
+                                .build());
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        // Add VARBINARY and CHAR columns that should be converted to BLOB
+        List<AddColumnEvent.ColumnWithPosition> addedColumns = new 
ArrayList<>();
+        addedColumns.add(
+                new AddColumnEvent.ColumnWithPosition(
+                        Column.physicalColumn(
+                                "new_blob_col",
+                                
org.apache.flink.cdc.common.types.DataTypes.VARBINARY(100))));
+        addedColumns.add(
+                new AddColumnEvent.ColumnWithPosition(
+                        Column.physicalColumn(
+                                "char_blob_col",
+                                
org.apache.flink.cdc.common.types.DataTypes.CHAR(100))));
+        AddColumnEvent addColumnEvent =
+                new 
AddColumnEvent(TableId.parse("test.add_blob_column_table"), addedColumns);
+        metadataApplier.applySchemaChange(addColumnEvent);
+
+        Table table = 
catalog.getTable(Identifier.fromString("test.add_blob_column_table"));
+        RowType rowType = table.rowType();
+
+        // Verify the added column is BLOB type
+        assertThat(rowType.getField("new_blob_col").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.BLOB);
+        assertThat(rowType.getField("char_blob_col").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.BLOB);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"filesystem"})
+    void testCreateAppendOnlyTableWithBinaryToBlob(String metastore)
+            throws Catalog.TableNotExistException,
+                    Catalog.DatabaseNotEmptyException,
+                    Catalog.DatabaseNotExistException,
+                    SchemaEvolveException {
+        initialize(metastore);
+        Map<String, String> tableOptions = new HashMap<>();
+        // Configure blob-field for BINARY type conversion
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), "fixed_content");
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        MetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        // Create append-only table with BINARY column
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(
+                        TableId.parse("test.binary_to_blob_table"),
+                        org.apache.flink.cdc.common.schema.Schema.newBuilder()
+                                .physicalColumn(
+                                        "id",
+                                        
org.apache.flink.cdc.common.types.DataTypes.INT().notNull())
+                                .physicalColumn(
+                                        "fixed_content",
+                                        
org.apache.flink.cdc.common.types.DataTypes.BINARY(10))
+                                .build());
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Table table = 
catalog.getTable(Identifier.fromString("test.binary_to_blob_table"));
+        RowType rowType = table.rowType();
+
+        // Verify BINARY is also converted to BLOB type
+        assertThat(rowType.getField("fixed_content").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.BLOB);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"filesystem"})
+    void testCreateAppendOnlyTableWithVarcharToBlob(String metastore)
+            throws Catalog.TableNotExistException,
+                    Catalog.DatabaseNotEmptyException,
+                    Catalog.DatabaseNotExistException,
+                    SchemaEvolveException {
+        initialize(metastore);
+        Map<String, String> tableOptions = new HashMap<>();
+        // Configure blob-field for VARCHAR type conversion (path string case)
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), "blob_path");
+        tableOptions.put(CoreOptions.BLOB_AS_DESCRIPTOR.key(), "true");
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.BUCKET.key(), "-1");
+        MetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        // Create append-only table with VARCHAR column (representing blob 
path)
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(
+                        TableId.parse("test.varchar_to_blob_table"),
+                        org.apache.flink.cdc.common.schema.Schema.newBuilder()
+                                .physicalColumn(
+                                        "id",
+                                        
org.apache.flink.cdc.common.types.DataTypes.INT().notNull())
+                                .physicalColumn(
+                                        "blob_path",
+                                        
org.apache.flink.cdc.common.types.DataTypes.VARCHAR(500))
+                                .build());
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Table table = 
catalog.getTable(Identifier.fromString("test.varchar_to_blob_table"));
+        RowType rowType = table.rowType();
+
+        // Verify VARCHAR is converted to BLOB type
+        assertThat(rowType.getField("blob_path").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.BLOB);
+        assertThat(rowType.getField("id").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.INTEGER);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"filesystem"})
+    void testCreateAppendOnlyTableWithStringToBlob(String metastore)
+            throws Catalog.TableNotExistException,
+                    Catalog.DatabaseNotEmptyException,
+                    Catalog.DatabaseNotExistException,
+                    SchemaEvolveException {
+        initialize(metastore);
+        Map<String, String> tableOptions = new HashMap<>();
+        // Configure blob-field for STRING type conversion (path string case)
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), "blob_uri");
+        tableOptions.put(CoreOptions.BLOB_AS_DESCRIPTOR.key(), "true");
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.BUCKET.key(), "-1");
+        MetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        // Create append-only table with STRING column (representing blob URI)
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(
+                        TableId.parse("test.string_to_blob_table"),
+                        org.apache.flink.cdc.common.schema.Schema.newBuilder()
+                                .physicalColumn(
+                                        "id",
+                                        
org.apache.flink.cdc.common.types.DataTypes.INT().notNull())
+                                .physicalColumn(
+                                        "blob_uri",
+                                        
org.apache.flink.cdc.common.types.DataTypes.STRING())
+                                .build());
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Table table = 
catalog.getTable(Identifier.fromString("test.string_to_blob_table"));
+        RowType rowType = table.rowType();
+
+        // Verify STRING is converted to BLOB type
+        assertThat(rowType.getField("blob_uri").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.BLOB);
+        assertThat(rowType.getField("id").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.INTEGER);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"filesystem"})
+    void testCreateAppendOnlyTableWithCharToBlob(String metastore)
+            throws Catalog.TableNotExistException,
+                    Catalog.DatabaseNotEmptyException,
+                    Catalog.DatabaseNotExistException,
+                    SchemaEvolveException {
+        initialize(metastore);
+        Map<String, String> tableOptions = new HashMap<>();
+        // Configure blob-field for CHAR type conversion (path string case)
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), "blob_path");
+        tableOptions.put(CoreOptions.BLOB_AS_DESCRIPTOR.key(), "true");
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.BUCKET.key(), "-1");
+        MetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        // Create append-only table with CHAR column (representing blob path)
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(
+                        TableId.parse("test.char_to_blob_table"),
+                        org.apache.flink.cdc.common.schema.Schema.newBuilder()
+                                .physicalColumn(
+                                        "id",
+                                        
org.apache.flink.cdc.common.types.DataTypes.INT().notNull())
+                                .physicalColumn(
+                                        "blob_path",
+                                        
org.apache.flink.cdc.common.types.DataTypes.CHAR(500))
+                                .build());
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Table table = 
catalog.getTable(Identifier.fromString("test.char_to_blob_table"));
+        RowType rowType = table.rowType();
+
+        // Verify CHAR is converted to BLOB type
+        assertThat(rowType.getField("blob_path").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.BLOB);
+        assertThat(rowType.getField("id").type().getTypeRoot())
+                .isEqualTo(org.apache.paimon.types.DataTypeRoot.INTEGER);
+    }
 }
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/AppendOnlyTableITCase.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/AppendOnlyTableITCase.java
new file mode 100644
index 000000000..32abdb215
--- /dev/null
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/AppendOnlyTableITCase.java
@@ -0,0 +1,209 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.cdc.connectors.paimon.sink.v2;
+
+import org.apache.flink.cdc.common.configuration.Configuration;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.pipeline.PipelineOptions;
+import org.apache.flink.cdc.composer.PipelineExecution;
+import org.apache.flink.cdc.composer.definition.PipelineDef;
+import org.apache.flink.cdc.composer.definition.SinkDef;
+import org.apache.flink.cdc.composer.definition.SourceDef;
+import org.apache.flink.cdc.composer.flink.FlinkPipelineComposer;
+import org.apache.flink.cdc.connectors.paimon.sink.PaimonDataSinkFactory;
+import org.apache.flink.cdc.connectors.paimon.sink.PaimonDataSinkOptions;
+import org.apache.flink.cdc.connectors.values.factory.ValuesDataFactory;
+import org.apache.flink.cdc.connectors.values.source.ValuesDataSourceHelper;
+import org.apache.flink.cdc.connectors.values.source.ValuesDataSourceOptions;
+import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
+import org.apache.flink.test.junit5.MiniClusterExtension;
+
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.flink.FlinkCatalogFactory;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.TableRead;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static 
org.apache.flink.configuration.CoreOptions.ALWAYS_PARENT_FIRST_LOADER_PATTERNS_ADDITIONAL;
+
+/** The ITCase for writing to append-only tables using custom test source. */
+public class AppendOnlyTableITCase {
+
+    @TempDir public static java.nio.file.Path temporaryFolder;
+
+    String warehouse;
+
+    private static final int PARALLELISM = 4;
+
+    private static final TableId TEST_TABLE_ID = 
TableId.tableId("default_database", "table1");
+
+    // Always use parent-first classloader for CDC classes.
+    // The reason is that test source uses static field for holding data, we 
need to make sure
+    // the class is loaded by AppClassloader so that we can verify data in the 
test case.
+    private static final org.apache.flink.configuration.Configuration 
MINI_CLUSTER_CONFIG =
+            new org.apache.flink.configuration.Configuration();
+
+    static {
+        MINI_CLUSTER_CONFIG.set(
+                ALWAYS_PARENT_FIRST_LOADER_PATTERNS_ADDITIONAL,
+                Collections.singletonList("org.apache.flink.cdc"));
+    }
+
+    /**
+     * Use {@link MiniClusterExtension} to reduce the overhead of restarting 
the MiniCluster for
+     * every test case.
+     */
+    @RegisterExtension
+    static final MiniClusterExtension MINI_CLUSTER_RESOURCE =
+            new MiniClusterExtension(
+                    new MiniClusterResourceConfiguration.Builder()
+                            .setNumberTaskManagers(1)
+                            .setNumberSlotsPerTaskManager(PARALLELISM)
+                            .setConfiguration(MINI_CLUSTER_CONFIG)
+                            .build());
+
+    @BeforeEach
+    void init() {
+        warehouse = new File(temporaryFolder.toFile(), 
UUID.randomUUID().toString()).toString();
+    }
+
+    @Test
+    void testWritingToAppendOnlyTable() throws Exception {
+        Options catalogOptions = new Options();
+        catalogOptions.setString("warehouse", warehouse);
+        catalogOptions.setString("cache-enabled", "false");
+        Catalog catalog = 
FlinkCatalogFactory.createPaimonCatalog(catalogOptions);
+
+        runPipelineJob(false);
+
+        Table table = 
catalog.getTable(Identifier.fromString(TEST_TABLE_ID.identifier()));
+        List<String> results = getResultsFromTable(table, false);
+        Assertions.assertThat(results)
+                .containsExactlyInAnyOrder(
+                        
"default_database.table1:uuid=550e8400-e29b-41d4-a716-446655440000;path=oss://my-bucket/data/files/document_001.pdf;blobContent=PDF
 binary content for document 001",
+                        
"default_database.table1:uuid=6ba7b810-9dad-11d1-80b4-00c04fd430c8;path=oss://my-bucket/data/images/photo_002.jpg;blobContent=JPG
 binary content for photo 002",
+                        
"default_database.table1:uuid=f47ac10b-58cc-4372-a567-0e02b2c3d479;path=oss://my-bucket/data/videos/movie_003.mp4;blobContent=MP4
 binary content for movie 003");
+    }
+
+    @Test
+    void testWritingToAppendOnlyTableWithBlobDescriptor() throws Exception {
+        Options catalogOptions = new Options();
+        catalogOptions.setString("warehouse", warehouse);
+        catalogOptions.setString("cache-enabled", "false");
+        Catalog catalog = 
FlinkCatalogFactory.createPaimonCatalog(catalogOptions);
+
+        runPipelineJob(true);
+
+        Table table = 
catalog.getTable(Identifier.fromString(TEST_TABLE_ID.identifier()));
+        List<String> results = getResultsFromTable(table, true);
+        Assertions.assertThat(results)
+                .containsExactlyInAnyOrder(
+                        
"default_database.table1:uuid=550e8400-e29b-41d4-a716-446655440000;path=oss://my-bucket/data/files/document_001.pdf;blobContent=PDF
 binary content for document 001",
+                        
"default_database.table1:uuid=6ba7b810-9dad-11d1-80b4-00c04fd430c8;path=oss://my-bucket/data/images/photo_002.jpg;blobContent=JPG
 binary content for photo 002",
+                        
"default_database.table1:uuid=f47ac10b-58cc-4372-a567-0e02b2c3d479;path=oss://my-bucket/data/videos/movie_003.mp4;blobContent=MP4
 binary content for movie 003");
+    }
+
+    private List<String> getResultsFromTable(Table table, boolean 
withBlobDescriptor)
+            throws IOException {
+        ReadBuilder readBuilder = table.newReadBuilder();
+        List<Split> splits = readBuilder.newScan().plan().splits();
+        TableRead read = readBuilder.newRead();
+        List<String> result = new ArrayList<>();
+        RecordReader<InternalRow> reader = read.createReader(splits);
+        reader.forEachRemaining(
+                record -> {
+                    String uuid = record.getString(0).toString();
+
+                    String path =
+                            withBlobDescriptor
+                                    ? record.getBlob(1).toDescriptor().uri()
+                                    : record.getString(1).toString();
+                    String blobContent = new String(record.getBinary(2));
+                    result.add(
+                            String.format(
+                                    "%s:uuid=%s;path=%s;blobContent=%s",
+                                    TEST_TABLE_ID.identifier(), uuid, path, 
blobContent));
+                });
+        return result;
+    }
+
+    private void runPipelineJob(boolean withBlobDescriptor) throws Exception {
+        FlinkPipelineComposer composer = FlinkPipelineComposer.ofMiniCluster();
+
+        // Setup values source with APPEND_ONLY_BLOB_TABLE events
+        Configuration sourceConfig = new Configuration();
+        sourceConfig.set(
+                ValuesDataSourceOptions.EVENT_SET_ID,
+                ValuesDataSourceHelper.EventSetId.APPEND_ONLY_BLOB_TABLE);
+        SourceDef sourceDef =
+                new SourceDef(
+                        ValuesDataFactory.IDENTIFIER, "Append Only Table 
Source", sourceConfig);
+
+        // Setup pipeline
+        Configuration pipelineConfig = new Configuration();
+        pipelineConfig.set(PipelineOptions.PIPELINE_PARALLELISM, PARALLELISM);
+        pipelineConfig.set(PipelineOptions.PIPELINE_LOCAL_TIME_ZONE, 
"Asia/Shanghai");
+
+        // Setup paimon sink
+        Map<String, String> sinkOptions = new HashMap<>();
+        sinkOptions.put("table.properties.parquet.use-native", "false");
+        if (withBlobDescriptor) {
+            sinkOptions.put("table.properties.row-tracking.enabled", "true");
+            sinkOptions.put("table.properties.data-evolution.enabled", "true");
+            sinkOptions.put("table.properties.blob-field", "path");
+            sinkOptions.put("table.properties.blob-descriptor-field", "path");
+            sinkOptions.put("table.properties.blob-as-descriptor", "true");
+        }
+        Configuration sinkConfig = Configuration.fromMap(sinkOptions);
+        sinkConfig.set(PaimonDataSinkOptions.METASTORE, "filesystem");
+        sinkConfig.set(PaimonDataSinkOptions.WAREHOUSE, warehouse);
+        SinkDef sinkDef = new SinkDef(PaimonDataSinkFactory.IDENTIFIER, 
"Paimon Sink", sinkConfig);
+        PipelineDef pipelineDef =
+                new PipelineDef(
+                        sourceDef,
+                        sinkDef,
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        pipelineConfig);
+
+        // Execute the pipeline
+        PipelineExecution execution = composer.compose(pipelineDef);
+
+        execution.execute();
+    }
+}
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonSinkITCase.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonSinkITCase.java
index 9a9ab6a42..028b189a9 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonSinkITCase.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonSinkITCase.java
@@ -301,7 +301,8 @@ public class PaimonSinkITCase {
         initialize(metastore);
         PaimonSink<Event> paimonSink =
                 new PaimonSink<>(
-                        catalogOptions, new 
PaimonRecordEventSerializer(ZoneId.systemDefault()));
+                        catalogOptions,
+                        new 
PaimonRecordEventSerializer(ZoneId.systemDefault(), catalogOptions));
         PaimonWriter<Event> writer = paimonSink.createWriter(new 
MockInitContext());
         Committer<MultiTableCommittable> committer =
                 paimonSink.createCommitter(new MockCommitterInitContext());
@@ -363,7 +364,8 @@ public class PaimonSinkITCase {
         initialize(metastore);
         PaimonSink<Event> paimonSink =
                 new PaimonSink<>(
-                        catalogOptions, new 
PaimonRecordEventSerializer(ZoneId.systemDefault()));
+                        catalogOptions,
+                        new 
PaimonRecordEventSerializer(ZoneId.systemDefault(), catalogOptions));
         PaimonWriter<Event> writer = paimonSink.createWriter(new 
MockInitContext());
         Committer<MultiTableCommittable> committer =
                 paimonSink.createCommitter(new MockCommitterInitContext());
@@ -429,7 +431,8 @@ public class PaimonSinkITCase {
         initialize(metastore);
         PaimonSink<Event> paimonSink =
                 new PaimonSink<>(
-                        catalogOptions, new 
PaimonRecordEventSerializer(ZoneId.systemDefault()));
+                        catalogOptions,
+                        new 
PaimonRecordEventSerializer(ZoneId.systemDefault(), catalogOptions));
         PaimonWriter<Event> writer = paimonSink.createWriter(new 
MockInitContext());
         Committer<MultiTableCommittable> committer =
                 paimonSink.createCommitter(new MockCommitterInitContext());
@@ -537,7 +540,8 @@ public class PaimonSinkITCase {
         initialize("filesystem");
         PaimonSink<Event> paimonSink =
                 new PaimonSink<>(
-                        catalogOptions, new 
PaimonRecordEventSerializer(ZoneId.systemDefault()));
+                        catalogOptions,
+                        new 
PaimonRecordEventSerializer(ZoneId.systemDefault(), catalogOptions));
         PaimonWriter<Event> writer = paimonSink.createWriter(new 
MockInitContext());
         Committer<MultiTableCommittable> committer =
                 paimonSink.createCommitter(new MockCommitterInitContext());
@@ -777,7 +781,8 @@ public class PaimonSinkITCase {
         initialize(metastore);
         PaimonSink<Event> paimonSink =
                 new PaimonSink<>(
-                        catalogOptions, new 
PaimonRecordEventSerializer(ZoneId.systemDefault()));
+                        catalogOptions,
+                        new 
PaimonRecordEventSerializer(ZoneId.systemDefault(), catalogOptions));
         PaimonWriter<Event> writer = paimonSink.createWriter(new 
MockInitContext());
         Committer<MultiTableCommittable> committer =
                 paimonSink.createCommitter(new MockCommitterInitContext());
@@ -908,7 +913,8 @@ public class PaimonSinkITCase {
         initialize(metastore);
         PaimonSink<Event> paimonSink =
                 new PaimonSink<>(
-                        catalogOptions, new 
PaimonRecordEventSerializer(ZoneId.systemDefault()));
+                        catalogOptions,
+                        new 
PaimonRecordEventSerializer(ZoneId.systemDefault(), catalogOptions));
         PaimonWriter<Event> writer = paimonSink.createWriter(new 
MockInitContext());
         Committer<MultiTableCommittable> committer =
                 paimonSink.createCommitter(new MockCommitterInitContext());
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriterHelperTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriterHelperTest.java
index 41accd73a..f43562c3e 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriterHelperTest.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriterHelperTest.java
@@ -35,12 +35,15 @@ import org.apache.flink.cdc.common.types.DataTypes;
 import org.apache.flink.cdc.common.types.RowType;
 import org.apache.flink.cdc.common.types.variant.BinaryVariantInternalBuilder;
 import org.apache.flink.cdc.connectors.paimon.sink.PaimonMetadataApplier;
+import org.apache.flink.cdc.connectors.paimon.sink.v2.blob.BlobWriteContext;
 import org.apache.flink.cdc.runtime.serializer.data.MapDataSerializer;
 import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.Blob;
 import org.apache.paimon.data.Decimal;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.InternalMap;
@@ -50,6 +53,7 @@ import org.apache.paimon.data.variant.GenericVariant;
 import org.apache.paimon.flink.FlinkCatalogFactory;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.table.Table;
+import org.apache.paimon.types.DataTypeRoot;
 import org.apache.paimon.types.RowKind;
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -475,6 +479,365 @@ class PaimonWriterHelperTest {
                 .isTrue();
     }
 
+    // ========== BLOB type support tests ==========
+
+    @Test
+    void testCreateFieldGettersWithBlobWriteContext() throws IOException {
+        RowType rowType =
+                RowType.of(DataTypes.INT(), DataTypes.VARBINARY(100), 
DataTypes.VARBINARY(50));
+        Object[] testData =
+                new Object[] {1, new byte[] {1, 2, 3, 4, 5}, new byte[] {6, 7, 
8, 9, 10}};
+        BinaryRecordData recordData = new 
BinaryRecordDataGenerator(rowType).generate(testData);
+        Schema schema = Schema.newBuilder().fromRowDataType(rowType).build();
+
+        // Create BlobWriteContext with "col2" as blob field
+        BlobWriteContext blobWriteContext = BlobWriteContext.empty();
+        // Note: We can't directly set blob fields, but we can test the empty 
case
+        List<RecordData.FieldGetter> fieldGettersNoBlob =
+                PaimonWriterHelper.createFieldGetters(schema, 
ZoneId.of("UTC+8"), blobWriteContext);
+
+        DataChangeEvent dataChangeEvent =
+                DataChangeEvent.insertEvent(TableId.parse("database.table"), 
recordData);
+        GenericRow genericRow =
+                PaimonWriterHelper.convertEventToGenericRow(dataChangeEvent, 
fieldGettersNoBlob);
+
+        // Without BlobWriteContext, VARBINARY should remain as byte[]
+        
Assertions.assertThat(genericRow.getField(1)).isInstanceOf(byte[].class);
+        
Assertions.assertThat(genericRow.getField(2)).isInstanceOf(byte[].class);
+    }
+
+    @Test
+    void testDeduceSchemaForPaimonTableWithBlobType() throws 
Catalog.TableNotExistException {
+        Options catalogOptions = new Options();
+        String warehouse =
+                new File(temporaryFolder.toFile(), 
UUID.randomUUID().toString()).toString();
+        catalogOptions.setString("warehouse", warehouse);
+        catalogOptions.setString("cache-enabled", "false");
+
+        // Create an append-only table with BLOB column using 
PaimonMetadataApplier
+        Map<String, String> tableOptions = new HashMap<>();
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), "blob_col");
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+
+        PaimonMetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        Schema cdcSchema =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.INT().notNull())
+                        .physicalColumn("blob_col", DataTypes.VARBINARY(100))
+                        .physicalColumn("normal_varbinary", 
DataTypes.VARBINARY(50))
+                        .build();
+
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(TableId.parse("test.blob_table"), 
cdcSchema);
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Catalog catalog = 
FlinkCatalogFactory.createPaimonCatalog(catalogOptions);
+        Table table = 
catalog.getTable(Identifier.fromString("test.blob_table"));
+
+        // Verify the table has BLOB type for blob_col
+        org.apache.paimon.types.RowType paimonRowType = table.rowType();
+        
Assertions.assertThat(paimonRowType.getField("blob_col").type().getTypeRoot())
+                .isEqualTo(DataTypeRoot.BLOB);
+        
Assertions.assertThat(paimonRowType.getField("normal_varbinary").type().getTypeRoot())
+                .isEqualTo(DataTypeRoot.VARBINARY);
+
+        // Verify deduceSchemaForPaimonTable converts BLOB back to VARBINARY
+        // (raw data mode: no blob-descriptor-field configured)
+        Schema deducedSchema = 
PaimonWriterHelper.deduceSchemaForPaimonTable(table);
+        
Assertions.assertThat(deducedSchema.getColumns().get(1).getType().getTypeRoot())
+                
.isEqualTo(org.apache.flink.cdc.common.types.DataTypeRoot.VARBINARY);
+        
Assertions.assertThat(deducedSchema.getColumns().get(2).getType().getTypeRoot())
+                
.isEqualTo(org.apache.flink.cdc.common.types.DataTypeRoot.VARBINARY);
+    }
+
+    @Test
+    void testDeduceSchemaForPaimonTableWithBlobDescriptorField()
+            throws Catalog.TableNotExistException {
+        Options catalogOptions = new Options();
+        String warehouse =
+                new File(temporaryFolder.toFile(), 
UUID.randomUUID().toString()).toString();
+        catalogOptions.setString("warehouse", warehouse);
+        catalogOptions.setString("cache-enabled", "false");
+
+        // Create table with blob-descriptor-field configuration (descriptor 
mode)
+        // In descriptor mode, blob column stores URI/path string instead of 
raw bytes
+        Map<String, String> tableOptions = new HashMap<>();
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), 
"descriptor_blob,raw_blob");
+        tableOptions.put(CoreOptions.BLOB_DESCRIPTOR_FIELD.key(), 
"descriptor_blob");
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.BUCKET.key(), "-1");
+
+        PaimonMetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        Schema cdcSchema =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.INT().notNull())
+                        .physicalColumn("descriptor_blob", DataTypes.STRING()) 
// URI path string
+                        .physicalColumn("raw_blob", DataTypes.VARBINARY(100)) 
// Raw bytes
+                        .build();
+
+        CreateTableEvent createTableEvent =
+                new 
CreateTableEvent(TableId.parse("test.blob_descriptor_schema_table"), cdcSchema);
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Catalog catalog = 
FlinkCatalogFactory.createPaimonCatalog(catalogOptions);
+        Table table = 
catalog.getTable(Identifier.fromString("test.blob_descriptor_schema_table"));
+
+        // Verify both columns become BLOB type in Paimon table
+        org.apache.paimon.types.RowType paimonRowType = table.rowType();
+        
Assertions.assertThat(paimonRowType.getField("descriptor_blob").type().getTypeRoot())
+                .isEqualTo(DataTypeRoot.BLOB);
+        
Assertions.assertThat(paimonRowType.getField("raw_blob").type().getTypeRoot())
+                .isEqualTo(DataTypeRoot.BLOB);
+
+        // Verify deduceSchemaForPaimonTable correctly converts:
+        // - descriptor_blob (blob-descriptor-field) → STRING
+        // - raw_blob (no blob-descriptor-field) → VARBINARY
+        Schema deducedSchema = 
PaimonWriterHelper.deduceSchemaForPaimonTable(table);
+        Assertions.assertThat(deducedSchema.getColumns().get(1).getName())
+                .isEqualTo("descriptor_blob");
+        
Assertions.assertThat(deducedSchema.getColumns().get(1).getType().getTypeRoot())
+                
.isEqualTo(org.apache.flink.cdc.common.types.DataTypeRoot.VARCHAR);
+        
Assertions.assertThat(deducedSchema.getColumns().get(2).getName()).isEqualTo("raw_blob");
+        
Assertions.assertThat(deducedSchema.getColumns().get(2).getType().getTypeRoot())
+                
.isEqualTo(org.apache.flink.cdc.common.types.DataTypeRoot.VARBINARY);
+    }
+
+    @Test
+    void testBlobWriteContextCreateBlobFromBytes() {
+        BlobWriteContext context = BlobWriteContext.empty();
+
+        // Test createBlob(byte[]) - 方式A: direct data storage
+        byte[] testData = new byte[] {1, 2, 3, 4, 5};
+        Blob blob = context.createBlob(testData);
+
+        Assertions.assertThat(blob).isNotNull();
+        Assertions.assertThat(blob.toData()).isEqualTo(testData);
+
+        // Test createBlob with null bytes
+        Blob nullBlob = context.createBlob((byte[]) null);
+        Assertions.assertThat(nullBlob).isNull();
+    }
+
+    @Test
+    void testBlobWriteContextFromTableWithBlobDescriptorField()
+            throws Catalog.TableNotExistException, IOException {
+        Options catalogOptions = new Options();
+        String warehouse =
+                new File(temporaryFolder.toFile(), 
UUID.randomUUID().toString()).toString();
+        catalogOptions.setString("warehouse", warehouse);
+        catalogOptions.setString("cache-enabled", "false");
+
+        // Create an append-only table with blob-descriptor-field
+        Map<String, String> tableOptions = new HashMap<>();
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), "blob_col");
+        tableOptions.put(CoreOptions.BLOB_DESCRIPTOR_FIELD.key(), "blob_col"); 
// descriptor mode
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.BUCKET.key(), "-1");
+
+        PaimonMetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        Schema cdcSchema =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.INT().notNull())
+                        .physicalColumn("blob_col", DataTypes.STRING()) // 
String path
+                        .build();
+
+        CreateTableEvent createTableEvent =
+                new 
CreateTableEvent(TableId.parse("test.blob_descriptor_field_table"), cdcSchema);
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Catalog catalog = 
FlinkCatalogFactory.createPaimonCatalog(catalogOptions);
+        org.apache.paimon.table.FileStoreTable table =
+                (org.apache.paimon.table.FileStoreTable)
+                        
catalog.getTable(Identifier.fromString("test.blob_descriptor_field_table"));
+
+        // Create BlobWriteContext from table (case-sensitive mode)
+        BlobWriteContext blobWriteContext = BlobWriteContext.fromTable(true, 
table);
+
+        // Verify blob-descriptor-field is parsed correctly
+        
Assertions.assertThat(blobWriteContext.isBlobDescriptorField("blob_col")).isTrue();
+        
Assertions.assertThat(blobWriteContext.isBlobDescriptorField("BLOB_COL"))
+                .isFalse(); // case-sensitive
+        
Assertions.assertThat(blobWriteContext.getBlobDescriptorFields()).contains("blob_col");
+        
Assertions.assertThat(blobWriteContext.isBlobField("blob_col")).isTrue();
+        
Assertions.assertThat(blobWriteContext.isBlobField("BLOB_COL")).isFalse(); // 
case-sensitive
+
+        // Test createBlobRef works correctly - stores descriptor info only
+        String externalPath = "oss://bucket/data/video.mp4";
+        Blob blobRef = blobWriteContext.createBlobRef(externalPath);
+        Assertions.assertThat(blobRef).isNotNull();
+        org.apache.paimon.data.BlobDescriptor descriptor = 
blobRef.toDescriptor();
+        Assertions.assertThat(descriptor.uri()).isEqualTo(externalPath);
+    }
+
+    @Test
+    void testBlobWriteContextMixedDescriptorAndRawBlobFields()
+            throws Catalog.TableNotExistException, IOException {
+        Options catalogOptions = new Options();
+        String warehouse =
+                new File(temporaryFolder.toFile(), 
UUID.randomUUID().toString()).toString();
+        catalogOptions.setString("warehouse", warehouse);
+        catalogOptions.setString("cache-enabled", "false");
+
+        // Create table with multiple blob fields: some descriptor, some raw
+        Map<String, String> tableOptions = new HashMap<>();
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), 
"descriptor_blob,raw_blob");
+        tableOptions.put(
+                CoreOptions.BLOB_DESCRIPTOR_FIELD.key(),
+                "descriptor_blob"); // Only descriptor_blob preserves path
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.BUCKET.key(), "-1");
+
+        PaimonMetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        Schema cdcSchema =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.INT().notNull())
+                        .physicalColumn("descriptor_blob", DataTypes.STRING()) 
// Path string
+                        .physicalColumn("raw_blob", DataTypes.VARBINARY(100)) 
// Raw bytes
+                        .build();
+
+        CreateTableEvent createTableEvent =
+                new CreateTableEvent(TableId.parse("test.mixed_blob_table"), 
cdcSchema);
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Catalog catalog = 
FlinkCatalogFactory.createPaimonCatalog(catalogOptions);
+        org.apache.paimon.table.FileStoreTable table =
+                (org.apache.paimon.table.FileStoreTable)
+                        
catalog.getTable(Identifier.fromString("test.mixed_blob_table"));
+
+        // Create BlobWriteContext from table (case-sensitive mode)
+        BlobWriteContext blobWriteContext = BlobWriteContext.fromTable(true, 
table);
+
+        // Verify correct classification
+        
Assertions.assertThat(blobWriteContext.isBlobField("descriptor_blob")).isTrue();
+        
Assertions.assertThat(blobWriteContext.isBlobField("raw_blob")).isTrue();
+        Assertions.assertThat(blobWriteContext.isBlobField("Descriptor_Blob"))
+                .isFalse(); // case-sensitive
+        
Assertions.assertThat(blobWriteContext.isBlobDescriptorField("descriptor_blob")).isTrue();
+        
Assertions.assertThat(blobWriteContext.isBlobDescriptorField("raw_blob")).isFalse();
+        
Assertions.assertThat(blobWriteContext.isBlobDescriptorField("Descriptor_Blob"))
+                .isFalse(); // case-sensitive
+
+        // Verify createBlobRef for descriptor field
+        String externalPath = "file:///data/image.png";
+        Blob descriptorBlob = blobWriteContext.createBlobRef(externalPath);
+        Assertions.assertThat(descriptorBlob).isNotNull();
+        org.apache.paimon.data.BlobDescriptor descriptor = 
descriptorBlob.toDescriptor();
+        Assertions.assertThat(descriptor.uri()).isEqualTo(externalPath);
+
+        // Verify createBlob(byte[]) for raw field
+        byte[] rawData = new byte[] {1, 2, 3, 4, 5};
+        Blob rawBlob = blobWriteContext.createBlob(rawData);
+        Assertions.assertThat(rawBlob).isNotNull();
+        Assertions.assertThat(rawBlob.toData()).isEqualTo(rawData);
+    }
+
+    @Test
+    void testBlobWriteContextCaseInsensitive() throws 
Catalog.TableNotExistException, IOException {
+        Options catalogOptions = new Options();
+        String warehouse =
+                new File(temporaryFolder.toFile(), 
UUID.randomUUID().toString()).toString();
+        catalogOptions.setString("warehouse", warehouse);
+        catalogOptions.setString("cache-enabled", "false");
+
+        // Create table with blob fields (use lowercase in config, matching 
CDC schema column name)
+        Map<String, String> tableOptions = new HashMap<>();
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), "blob_col");
+        tableOptions.put(CoreOptions.BLOB_DESCRIPTOR_FIELD.key(), "blob_col");
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.BUCKET.key(), "-1");
+
+        PaimonMetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        Schema cdcSchema =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.INT().notNull())
+                        .physicalColumn("blob_col", DataTypes.STRING())
+                        .build();
+
+        CreateTableEvent createTableEvent =
+                new 
CreateTableEvent(TableId.parse("test.case_insensitive_blob_table"), cdcSchema);
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Catalog catalog = 
FlinkCatalogFactory.createPaimonCatalog(catalogOptions);
+        org.apache.paimon.table.FileStoreTable table =
+                (org.apache.paimon.table.FileStoreTable)
+                        
catalog.getTable(Identifier.fromString("test.case_insensitive_blob_table"));
+
+        // Create BlobWriteContext with case-insensitive mode
+        BlobWriteContext blobWriteContext = BlobWriteContext.fromTable(false, 
table);
+
+        // Verify field names can match both uppercase and lowercase queries 
when case-insensitive
+        
Assertions.assertThat(blobWriteContext.isBlobField("blob_col")).isTrue();
+        
Assertions.assertThat(blobWriteContext.isBlobField("BLOB_COL")).isTrue();
+        
Assertions.assertThat(blobWriteContext.isBlobField("Blob_Col")).isTrue();
+        
Assertions.assertThat(blobWriteContext.isBlobDescriptorField("blob_col")).isTrue();
+        
Assertions.assertThat(blobWriteContext.isBlobDescriptorField("BLOB_COL")).isTrue();
+        
Assertions.assertThat(blobWriteContext.isBlobDescriptorField("Blob_Col")).isTrue();
+
+        // Verify internal storage is lowercase
+        
Assertions.assertThat(blobWriteContext.getBlobFields()).contains("blob_col");
+        
Assertions.assertThat(blobWriteContext.getBlobDescriptorFields()).contains("blob_col");
+    }
+
+    @Test
+    void testBlobWriteContextCaseSensitiveMismatch()
+            throws Catalog.TableNotExistException, IOException {
+        Options catalogOptions = new Options();
+        String warehouse =
+                new File(temporaryFolder.toFile(), 
UUID.randomUUID().toString()).toString();
+        catalogOptions.setString("warehouse", warehouse);
+        catalogOptions.setString("cache-enabled", "false");
+
+        // Create table with blob fields in lowercase config
+        Map<String, String> tableOptions = new HashMap<>();
+        tableOptions.put(CoreOptions.BLOB_FIELD.key(), "blob_col");
+        tableOptions.put(CoreOptions.BLOB_DESCRIPTOR_FIELD.key(), "blob_col");
+        tableOptions.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        tableOptions.put(CoreOptions.BUCKET.key(), "-1");
+
+        PaimonMetadataApplier metadataApplier =
+                new PaimonMetadataApplier(catalogOptions, tableOptions, new 
HashMap<>());
+
+        Schema cdcSchema =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.INT().notNull())
+                        .physicalColumn("blob_col", DataTypes.STRING())
+                        .build();
+
+        CreateTableEvent createTableEvent =
+                new 
CreateTableEvent(TableId.parse("test.case_sensitive_blob_table"), cdcSchema);
+        metadataApplier.applySchemaChange(createTableEvent);
+
+        Catalog catalog = 
FlinkCatalogFactory.createPaimonCatalog(catalogOptions);
+        org.apache.paimon.table.FileStoreTable table =
+                (org.apache.paimon.table.FileStoreTable)
+                        
catalog.getTable(Identifier.fromString("test.case_sensitive_blob_table"));
+
+        // Create BlobWriteContext with case-sensitive mode
+        BlobWriteContext blobWriteContext = BlobWriteContext.fromTable(true, 
table);
+
+        // Verify only exact case match works
+        
Assertions.assertThat(blobWriteContext.isBlobField("blob_col")).isTrue();
+        
Assertions.assertThat(blobWriteContext.isBlobField("BLOB_COL")).isFalse();
+        
Assertions.assertThat(blobWriteContext.isBlobDescriptorField("blob_col")).isTrue();
+        
Assertions.assertThat(blobWriteContext.isBlobDescriptorField("BLOB_COL")).isFalse();
+    }
+
     private static Map<BinaryString, BinaryString> extractMap(InternalMap 
internalMap) {
         Map<BinaryString, BinaryString> resultMap = new HashMap<>();
         for (int i = 0; i < internalMap.size(); i++) {
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriterTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriterTest.java
new file mode 100644
index 000000000..14005bb9a
--- /dev/null
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/PaimonWriterTest.java
@@ -0,0 +1,218 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.cdc.connectors.paimon.sink.v2;
+
+import org.apache.flink.cdc.common.data.binary.BinaryStringData;
+import org.apache.flink.cdc.common.event.AddColumnEvent;
+import org.apache.flink.cdc.common.event.AlterColumnTypeEvent;
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.DataChangeEvent;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.schema.Column;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.types.DataType;
+import org.apache.flink.cdc.common.types.DataTypes;
+import org.apache.flink.cdc.common.types.RowType;
+import org.apache.flink.cdc.connectors.paimon.sink.v2.blob.BlobWriteContext;
+import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
+import org.apache.flink.metrics.MetricGroup;
+import org.apache.flink.metrics.groups.UnregisteredMetricsGroup;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.flink.FlinkCatalogFactory;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.table.FileStoreTable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.lang.reflect.Field;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Unit tests for {@link PaimonWriter#write}. */
+@ExtendWith(MockitoExtension.class)
+class PaimonWriterTest {
+
+    @Mock private Catalog mockCatalog;
+
+    @Mock private FileStoreTable mockTable;
+
+    @Mock private CoreOptions mockCoreOptions;
+
+    private MockedStatic<FlinkCatalogFactory> mockedFactory;
+    private MockedStatic<BlobWriteContext> mockedBlobWriteContext;
+    private MockedConstruction<StoreSinkWriteImpl> mockedConstruction;
+
+    private PaimonWriter<Event> writer;
+
+    private final TableId testTableId = TableId.parse("test.table1");
+    private final Identifier testIdentifier = 
Identifier.fromString("test.table1");
+
+    @BeforeEach
+    void setUp() throws Exception {
+        mockedFactory = Mockito.mockStatic(FlinkCatalogFactory.class);
+        mockedBlobWriteContext = Mockito.mockStatic(BlobWriteContext.class);
+
+        mockedFactory
+                .when(() -> 
FlinkCatalogFactory.createPaimonCatalog(any(Options.class)))
+                .thenReturn(mockCatalog);
+
+        BlobWriteContext mockBlobContext = BlobWriteContext.empty();
+        mockedBlobWriteContext
+                .when(() -> BlobWriteContext.fromTable(anyBoolean(), 
any(FileStoreTable.class)))
+                .thenReturn(mockBlobContext);
+
+        
when(mockCatalog.getTable(any(Identifier.class))).thenReturn(mockTable);
+        when(mockCoreOptions.writeBufferSize()).thenReturn(32 * 1024 * 1024L);
+        when(mockCoreOptions.pageSize()).thenReturn(32 * 1024);
+        when(mockTable.coreOptions()).thenReturn(mockCoreOptions);
+
+        MetricGroup metricGroup = 
UnregisteredMetricsGroup.createOperatorMetricGroup();
+        Options catalogOptions = new Options();
+        PaimonRecordEventSerializer serializer =
+                new PaimonRecordEventSerializer(ZoneId.systemDefault(), 
catalogOptions);
+
+        mockedConstruction =
+                Mockito.mockConstruction(
+                        StoreSinkWriteImpl.class,
+                        (mock, context) ->
+                                lenient()
+                                        .when(mock.prepareCommit(anyBoolean(), 
anyLong()))
+                                        
.thenReturn(java.util.Collections.emptyList()));
+
+        writer =
+                new PaimonWriter<>(catalogOptions, metricGroup, 
"test_commit_user", serializer, 0L);
+
+        injectMockCatalog(writer, mockCatalog);
+    }
+
+    private void injectMockCatalog(PaimonWriter<Event> writer, Catalog 
catalog) throws Exception {
+        Field catalogField = PaimonWriter.class.getDeclaredField("catalog");
+        catalogField.setAccessible(true);
+        catalogField.set(writer, catalog);
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        if (writer != null) {
+            writer.close();
+        }
+        if (mockedFactory != null) {
+            mockedFactory.close();
+        }
+        if (mockedBlobWriteContext != null) {
+            mockedBlobWriteContext.close();
+        }
+        if (mockedConstruction != null) {
+            mockedConstruction.close();
+        }
+    }
+
+    @Test
+    void testGetTableInvocationCount() throws Exception {
+        Schema schema =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.STRING().notNull())
+                        .physicalColumn("value", DataTypes.INT())
+                        .physicalColumn("path", DataTypes.STRING())
+                        .build();
+
+        writer.write(new CreateTableEvent(testTableId, schema), null);
+
+        RowType rowType =
+                RowType.of(DataTypes.STRING().notNull(), DataTypes.INT(), 
DataTypes.STRING());
+        BinaryRecordDataGenerator generator = new 
BinaryRecordDataGenerator(rowType);
+
+        for (int i = 1; i <= 5; i++) {
+            writer.write(
+                    DataChangeEvent.insertEvent(
+                            testTableId,
+                            generator.generate(
+                                    new Object[] {
+                                        BinaryStringData.fromString("id" + i),
+                                        i,
+                                        BinaryStringData.fromString("path" + i)
+                                    })),
+                    null);
+        }
+
+        verify(mockCatalog, times(2)).getTable(testIdentifier);
+
+        List<AddColumnEvent.ColumnWithPosition> addedColumns = new 
ArrayList<>();
+        addedColumns.add(
+                new AddColumnEvent.ColumnWithPosition(
+                        Column.physicalColumn("age", DataTypes.INT())));
+        AddColumnEvent addColumnEvent = new AddColumnEvent(testTableId, 
addedColumns);
+        writer.write(addColumnEvent, null);
+
+        verify(mockCatalog, times(3)).getTable(testIdentifier);
+
+        RowType rowTypeWithAge =
+                RowType.of(
+                        DataTypes.STRING().notNull(),
+                        DataTypes.INT(),
+                        DataTypes.STRING(),
+                        DataTypes.INT());
+        BinaryRecordDataGenerator generatorWithAge = new 
BinaryRecordDataGenerator(rowTypeWithAge);
+
+        for (int i = 6; i <= 10; i++) {
+            writer.write(
+                    DataChangeEvent.insertEvent(
+                            testTableId,
+                            generatorWithAge.generate(
+                                    new Object[] {
+                                        BinaryStringData.fromString("id" + i),
+                                        i,
+                                        BinaryStringData.fromString("path" + 
i),
+                                        i + 10
+                                    })),
+                    null);
+        }
+
+        verify(mockCatalog, times(3)).getTable(testIdentifier);
+
+        Map<String, DataType> typeMapping = new HashMap<>();
+        typeMapping.put("value", DataTypes.BIGINT());
+        AlterColumnTypeEvent alterColumnTypeEvent =
+                new AlterColumnTypeEvent(testTableId, typeMapping);
+        writer.write(alterColumnTypeEvent, null);
+
+        verify(mockCatalog, times(4)).getTable(testIdentifier);
+    }
+}
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/bucket/PaimonPostponeBucketTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/bucket/PaimonPostponeBucketTest.java
index 3621f4d7a..aa2c43c65 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/bucket/PaimonPostponeBucketTest.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/v2/bucket/PaimonPostponeBucketTest.java
@@ -101,8 +101,11 @@ class PaimonPostponeBucketTest {
     @Test
     void testSerializerKeepsAssignedBucketBeforeWriterRewrite() throws 
Exception {
         Schema schema = createPostponeBucketSchema();
+        Options catalogOptions = createCatalogOptions();
+        new PaimonMetadataApplier(catalogOptions)
+                .applySchemaChange(new CreateTableEvent(TABLE_ID, schema));
         PaimonRecordSerializer<Event> serializer =
-                new PaimonRecordEventSerializer(ZoneId.systemDefault());
+                new PaimonRecordEventSerializer(ZoneId.systemDefault(), 
catalogOptions);
         serializer.serialize(new CreateTableEvent(TABLE_ID, schema));
         int assignedBucket = 2;
         BucketWrapperChangeEvent bucketWrapperChangeEvent =
@@ -147,7 +150,7 @@ class PaimonPostponeBucketTest {
                             catalogOptions,
                             
UnregisteredMetricsGroup.createSinkWriterMetricGroup(),
                             "test-user",
-                            new 
PaimonRecordEventSerializer(ZoneId.systemDefault()),
+                            new 
PaimonRecordEventSerializer(ZoneId.systemDefault(), catalogOptions),
                             0);
 
             writer.write(new CreateTableEvent(TABLE_ID, schema), null);
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-values/src/main/java/org/apache/flink/cdc/connectors/values/source/ValuesDataSourceHelper.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-values/src/main/java/org/apache/flink/cdc/connectors/values/source/ValuesDataSourceHelper.java
index bcb55094a..2d5ffd5d7 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-values/src/main/java/org/apache/flink/cdc/connectors/values/source/ValuesDataSourceHelper.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-values/src/main/java/org/apache/flink/cdc/connectors/values/source/ValuesDataSourceHelper.java
@@ -61,7 +61,8 @@ public class ValuesDataSourceHelper {
         SINGLE_SPLIT_SINGLE_BATCH_TABLE,
         SINGLE_SPLIT_MULTI_BATCH_TABLE,
         MULTI_SPLITS_SINGLE_BATCH_TABLE,
-        TRANSFORM_BATCH_TABLE;
+        TRANSFORM_BATCH_TABLE,
+        APPEND_ONLY_BLOB_TABLE;
 
         public boolean isBatchEvent() {
             switch (this) {
@@ -165,11 +166,78 @@ public class ValuesDataSourceHelper {
                     sourceEvents = transformBatchTable();
                     break;
                 }
+            case APPEND_ONLY_BLOB_TABLE:
+                {
+                    sourceEvents = appendOnlyBlobTable();
+                    break;
+                }
             default:
                 throw new IllegalArgumentException(eventType + " is not 
supported");
         }
     }
 
+    public static List<List<Event>> appendOnlyBlobTable() {
+        List<List<Event>> eventOfSplits = new ArrayList<>();
+        List<Event> split1 = new ArrayList<>();
+
+        TableId tableId = TableId.tableId("default_database", "table1");
+
+        // create table without primary key, with VARBINARY blob column
+        Schema schema =
+                Schema.newBuilder()
+                        .physicalColumn("uuid", DataTypes.STRING().notNull())
+                        .physicalColumn("path", DataTypes.STRING())
+                        .physicalColumn("blobContent", 
DataTypes.VARBINARY(1000))
+                        .build();
+        CreateTableEvent createTableEvent = new CreateTableEvent(tableId, 
schema);
+        split1.add(createTableEvent);
+
+        BinaryRecordDataGenerator generator =
+                new BinaryRecordDataGenerator(
+                        RowType.of(
+                                DataTypes.STRING().notNull(),
+                                DataTypes.STRING(),
+                                DataTypes.VARBINARY(1000)));
+
+        // insert 3 events with uuid, path and blobContent
+        split1.add(
+                DataChangeEvent.insertEvent(
+                        tableId,
+                        generator.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString(
+                                            
"550e8400-e29b-41d4-a716-446655440000"),
+                                    BinaryStringData.fromString(
+                                            
"oss://my-bucket/data/files/document_001.pdf"),
+                                    "PDF binary content for document 
001".getBytes()
+                                })));
+        split1.add(
+                DataChangeEvent.insertEvent(
+                        tableId,
+                        generator.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString(
+                                            
"6ba7b810-9dad-11d1-80b4-00c04fd430c8"),
+                                    BinaryStringData.fromString(
+                                            
"oss://my-bucket/data/images/photo_002.jpg"),
+                                    "JPG binary content for photo 
002".getBytes()
+                                })));
+        split1.add(
+                DataChangeEvent.insertEvent(
+                        tableId,
+                        generator.generate(
+                                new Object[] {
+                                    BinaryStringData.fromString(
+                                            
"f47ac10b-58cc-4372-a567-0e02b2c3d479"),
+                                    BinaryStringData.fromString(
+                                            
"oss://my-bucket/data/videos/movie_003.mp4"),
+                                    "MP4 binary content for movie 
003".getBytes()
+                                })));
+
+        eventOfSplits.add(split1);
+        return eventOfSplits;
+    }
+
     public static List<List<Event>> singleSplitSingleTable() {
         List<List<Event>> eventOfSplits = new ArrayList<>();
         List<Event> split1 = new ArrayList<>();

Reply via email to