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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 3c0fab6af4 [core] Add row sidecars for data evolution sparse reads 
(#8365)
3c0fab6af4 is described below

commit 3c0fab6af424d216fc5dbbdcea645381030a4f21
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Jun 27 08:45:09 2026 +0800

    [core] Add row sidecars for data evolution sparse reads (#8365)
    
    Add opt-in row-store sidecar files for data evolution tables so sparse
    row-id reads can use `.row` files while normal data files keep their
    configured columnar format. The implementation covers both Java and
    Python read/write paths and keeps dedicated blob/vector writers
    sidecar-free.
---
 docs/generated/core_configuration.html             |  18 ++
 .../main/java/org/apache/paimon/CoreOptions.java   |  52 +++++
 .../org/apache/paimon/append/AppendOnlyWriter.java |   6 +-
 .../org/apache/paimon/io/RowDataFileWriter.java    | 258 +++++++++++++++++++--
 .../apache/paimon/io/RowDataRollingFileWriter.java |  42 ++--
 .../paimon/operation/BaseAppendFileStoreWrite.java |  11 +-
 .../paimon/operation/DataEvolutionSplitRead.java   | 136 ++++++++++-
 .../apache/paimon/append/AppendOnlyWriterTest.java |   1 +
 .../DedicatedFormatRollingFileWriterTest.java      |  25 ++
 .../paimon/io/KeyValueFileReadWriteTest.java       |   1 +
 .../apache/paimon/io/RollingFileWriterTest.java    | 144 ++++++++++++
 .../operation/DataEvolutionSplitReadTest.java      | 243 +++++++++++++++++--
 .../paimon/table/DataEvolutionTableTest.java       |  78 +++++++
 .../pypaimon/common/options/core_options.py        |  46 ++++
 .../pypaimon/read/reader/format_row_reader.py      |  67 ++++--
 paimon-python/pypaimon/read/split_read.py          |  66 +++++-
 .../pypaimon/tests/data_evolution_formats_test.py  | 184 +++++++++++++++
 paimon-python/pypaimon/write/writer/data_writer.py |  82 +++++--
 18 files changed, 1369 insertions(+), 91 deletions(-)

diff --git a/docs/generated/core_configuration.html 
b/docs/generated/core_configuration.html
index 1e82e01041..3c88e12114 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -476,6 +476,24 @@ under the License.
             <td>Boolean</td>
             <td>Whether enable data evolution for row tracking table.</td>
         </tr>
+        <tr>
+            <td><h5>data-evolution.row-sidecar.enabled</h5></td>
+            <td style="word-wrap: break-word;">false</td>
+            <td>Boolean</td>
+            <td>Whether to generate row-store sidecar files for normal data 
files on data evolution tables. The sidecar files are used to accelerate sparse 
row-id reads.</td>
+        </tr>
+        <tr>
+            <td><h5>data-evolution.row-sidecar.max-selected-rows</h5></td>
+            <td style="word-wrap: break-word;">4096</td>
+            <td>Long</td>
+            <td>Maximum selected row count for reading a row-store sidecar 
file. The sidecar is used only when the selected rows are no more than this 
value and the selected row ratio is no more than 
data-evolution.row-sidecar.max-selection-ratio.</td>
+        </tr>
+        <tr>
+            <td><h5>data-evolution.row-sidecar.max-selection-ratio</h5></td>
+            <td style="word-wrap: break-word;">0.05</td>
+            <td>Double</td>
+            <td>Maximum selected row ratio for reading a row-store sidecar 
file. The value must be in (0, 1]. The sidecar is used only when the selected 
row ratio is no more than this value and the selected row count is no more than 
data-evolution.row-sidecar.max-selected-rows.</td>
+        </tr>
         <tr>
             <td><h5>data-evolution.merge-into.file-pruning</h5></td>
             <td style="word-wrap: break-word;">true</td>
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java 
b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index 8ca39caf66..e3b6b01952 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -2354,6 +2354,36 @@ public class CoreOptions implements Serializable {
                     .defaultValue(false)
                     .withDescription("Whether enable data evolution for row 
tracking table.");
 
+    public static final ConfigOption<Boolean> 
DATA_EVOLUTION_ROW_SIDECAR_ENABLED =
+            key("data-evolution.row-sidecar.enabled")
+                    .booleanType()
+                    .defaultValue(false)
+                    .withDescription(
+                            "Whether to generate row-store sidecar files for 
normal data files "
+                                    + "on data evolution tables. The sidecar 
files are used to "
+                                    + "accelerate sparse row-id reads.");
+
+    public static final ConfigOption<Long> 
DATA_EVOLUTION_ROW_SIDECAR_MAX_SELECTED_ROWS =
+            key("data-evolution.row-sidecar.max-selected-rows")
+                    .longType()
+                    .defaultValue(4096L)
+                    .withDescription(
+                            "Maximum selected row count for reading a 
row-store sidecar file. "
+                                    + "The sidecar is used only when the 
selected rows are no more "
+                                    + "than this value and the selected row 
ratio is no more than "
+                                    + 
"data-evolution.row-sidecar.max-selection-ratio.");
+
+    public static final ConfigOption<Double> 
DATA_EVOLUTION_ROW_SIDECAR_MAX_SELECTION_RATIO =
+            key("data-evolution.row-sidecar.max-selection-ratio")
+                    .doubleType()
+                    .defaultValue(0.05d)
+                    .withDescription(
+                            "Maximum selected row ratio for reading a 
row-store sidecar file. "
+                                    + "The value must be in (0, 1]. The 
sidecar is used only when "
+                                    + "the selected row ratio is no more than 
this value and the "
+                                    + "selected row count is no more than "
+                                    + 
"data-evolution.row-sidecar.max-selected-rows.");
+
     public static final ConfigOption<Boolean> 
DATA_EVOLUTION_MERGE_INTO_FILE_PRUNING =
             key("data-evolution.merge-into.file-pruning")
                     .booleanType()
@@ -3928,6 +3958,28 @@ public class CoreOptions implements Serializable {
         return options.get(DATA_EVOLUTION_ENABLED);
     }
 
+    public boolean dataEvolutionRowSidecarEnabled() {
+        return options.get(DATA_EVOLUTION_ROW_SIDECAR_ENABLED);
+    }
+
+    public long dataEvolutionRowSidecarMaxSelectedRows() {
+        long maxSelectedRows = 
options.get(DATA_EVOLUTION_ROW_SIDECAR_MAX_SELECTED_ROWS);
+        checkArgument(
+                maxSelectedRows > 0,
+                "The option %s must be greater than 0.",
+                DATA_EVOLUTION_ROW_SIDECAR_MAX_SELECTED_ROWS.key());
+        return maxSelectedRows;
+    }
+
+    public double dataEvolutionRowSidecarMaxSelectionRatio() {
+        double maxSelectionRatio = 
options.get(DATA_EVOLUTION_ROW_SIDECAR_MAX_SELECTION_RATIO);
+        checkArgument(
+                maxSelectionRatio > 0 && maxSelectionRatio <= 1,
+                "The option %s must be in (0, 1].",
+                DATA_EVOLUTION_ROW_SIDECAR_MAX_SELECTION_RATIO.key());
+        return maxSelectionRatio;
+    }
+
     public boolean dataEvolutionMergeIntoFilePruning() {
         return options.get(DATA_EVOLUTION_MERGE_INTO_FILE_PRUNING);
     }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/append/AppendOnlyWriter.java 
b/paimon-core/src/main/java/org/apache/paimon/append/AppendOnlyWriter.java
index 6f081bf2a0..66dcc75613 100644
--- a/paimon-core/src/main/java/org/apache/paimon/append/AppendOnlyWriter.java
+++ b/paimon-core/src/main/java/org/apache/paimon/append/AppendOnlyWriter.java
@@ -85,6 +85,7 @@ public class AppendOnlyWriter implements BatchRecordWriter, 
MemoryOwner {
     private final boolean forceCompact;
     private final boolean asyncFileWrite;
     private final boolean statsDenseStore;
+    @Nullable private final FileFormat rowSidecarFileFormat;
     @Nullable private final BlobFileContext blobContext;
     private final List<DataFileMeta> newFiles;
     private final List<DataFileMeta> deletedFiles;
@@ -129,6 +130,7 @@ public class AppendOnlyWriter implements BatchRecordWriter, 
MemoryOwner {
             boolean asyncFileWrite,
             boolean statsDenseStore,
             boolean dataEvolutionEnabled,
+            @Nullable FileFormat rowSidecarFileFormat,
             @Nullable BlobFileContext blobContext) {
         this.fileIO = fileIO;
         this.schemaId = schemaId;
@@ -145,6 +147,7 @@ public class AppendOnlyWriter implements BatchRecordWriter, 
MemoryOwner {
         this.forceCompact = forceCompact;
         this.asyncFileWrite = asyncFileWrite;
         this.statsDenseStore = statsDenseStore;
+        this.rowSidecarFileFormat = dataEvolutionEnabled ? 
rowSidecarFileFormat : null;
         this.blobContext = blobContext;
         this.newFiles = new ArrayList<>();
         this.deletedFiles = new ArrayList<>();
@@ -344,7 +347,8 @@ public class AppendOnlyWriter implements BatchRecordWriter, 
MemoryOwner {
                 FileSource.APPEND,
                 asyncFileWrite,
                 statsDenseStore,
-                writeCols);
+                writeCols,
+                rowSidecarFileFormat);
     }
 
     private void trySyncLatestCompaction(boolean blocking)
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/io/RowDataFileWriter.java 
b/paimon-core/src/main/java/org/apache/paimon/io/RowDataFileWriter.java
index aa3b203588..02dff0fd82 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/RowDataFileWriter.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/RowDataFileWriter.java
@@ -20,6 +20,8 @@ package org.apache.paimon.io;
 
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.fileindex.FileIndexOptions;
+import org.apache.paimon.format.FileFormat;
+import org.apache.paimon.format.FormatWriterFactory;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.manifest.FileSource;
@@ -32,8 +34,10 @@ import org.apache.paimon.utils.Pair;
 import javax.annotation.Nullable;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import java.util.function.Function;
 import java.util.function.Supplier;
 
@@ -48,7 +52,7 @@ public class RowDataFileWriter extends 
StatsCollectingSingleFileWriter<InternalR
     private final long schemaId;
     private final boolean isExternalPath;
     private final SimpleStatsConverter statsArraySerializer;
-    @Nullable private final DataFileIndexWriter dataFileIndexWriter;
+    private final List<DataFileAuxiliaryWriter> auxiliaryFileWriters;
     private final FileSource fileSource;
     @Nullable private final List<String> writeCols;
     private final RowDataFileSequenceNumberTracker sequenceNumberTracker;
@@ -66,13 +70,64 @@ public class RowDataFileWriter extends 
StatsCollectingSingleFileWriter<InternalR
             boolean statsDenseStore,
             boolean isExternalPath,
             @Nullable List<String> writeCols) {
+        this(
+                fileIO,
+                context,
+                path,
+                writeSchema,
+                schemaId,
+                seqNumCounterSupplier,
+                fileIndexOptions,
+                fileSource,
+                asyncFileWrite,
+                statsDenseStore,
+                isExternalPath,
+                writeCols,
+                null,
+                null);
+    }
+
+    public RowDataFileWriter(
+            FileIO fileIO,
+            FileWriterContext context,
+            Path path,
+            RowType writeSchema,
+            long schemaId,
+            Supplier<LongCounter> seqNumCounterSupplier,
+            FileIndexOptions fileIndexOptions,
+            FileSource fileSource,
+            boolean asyncFileWrite,
+            boolean statsDenseStore,
+            boolean isExternalPath,
+            @Nullable List<String> writeCols,
+            @Nullable FileFormat rowSidecarFormat,
+            @Nullable Path rowSidecarPath) {
         super(fileIO, context, path, Function.identity(), writeSchema, 
asyncFileWrite);
+        if ((rowSidecarFormat == null) != (rowSidecarPath == null)) {
+            throw new IllegalArgumentException(
+                    "Row sidecar format and path should be both null or both 
non-null.");
+        }
         this.schemaId = schemaId;
         this.isExternalPath = isExternalPath;
         this.statsArraySerializer = new SimpleStatsConverter(writeSchema, 
statsDenseStore);
-        this.dataFileIndexWriter =
-                DataFileIndexWriter.create(
-                        fileIO, dataFileToFileIndexPath(path), writeSchema, 
fileIndexOptions);
+        List<DataFileAuxiliaryWriter> auxiliaryFileWriters = new ArrayList<>();
+        Path fileIndexPath = dataFileToFileIndexPath(path);
+        DataFileIndexWriter dataFileIndexWriter =
+                DataFileIndexWriter.create(fileIO, fileIndexPath, writeSchema, 
fileIndexOptions);
+        if (dataFileIndexWriter != null) {
+            auxiliaryFileWriters.add(
+                    new DataFileIndexAuxiliaryWriter(dataFileIndexWriter, 
fileIO, fileIndexPath));
+        }
+        if (rowSidecarFormat != null) {
+            auxiliaryFileWriters.add(
+                    new RowSidecarAuxiliaryWriter(
+                            fileIO,
+                            rowSidecarFormat.createWriterFactory(writeSchema),
+                            rowSidecarPath,
+                            context.compression(),
+                            asyncFileWrite));
+        }
+        this.auxiliaryFileWriters = 
Collections.unmodifiableList(auxiliaryFileWriters);
         this.fileSource = fileSource;
         this.writeCols = writeCols;
         this.sequenceNumberTracker =
@@ -83,30 +138,73 @@ public class RowDataFileWriter extends 
StatsCollectingSingleFileWriter<InternalR
     @Override
     public void write(InternalRow row) throws IOException {
         super.write(row);
-        // add row to index if needed
-        if (dataFileIndexWriter != null) {
-            dataFileIndexWriter.write(row);
+        for (DataFileAuxiliaryWriter auxiliaryFileWriter : 
auxiliaryFileWriters) {
+            auxiliaryFileWriter.write(row);
         }
         sequenceNumberTracker.update(row);
     }
 
+    @Override
+    public void writeBundle(BundleRecords bundle) throws IOException {
+        for (InternalRow row : bundle) {
+            write(row);
+        }
+    }
+
     @Override
     public void close() throws IOException {
-        if (dataFileIndexWriter != null) {
-            dataFileIndexWriter.close();
+        for (DataFileAuxiliaryWriter auxiliaryFileWriter : 
auxiliaryFileWriters) {
+            auxiliaryFileWriter.close();
         }
         super.close();
     }
 
+    @Override
+    public void abort() {
+        for (DataFileAuxiliaryWriter auxiliaryFileWriter : 
auxiliaryFileWriters) {
+            auxiliaryFileWriter.abort();
+        }
+        super.abort();
+    }
+
+    @Override
+    public Optional<FileWriterAbortExecutor> abortExecutor() {
+        Optional<FileWriterAbortExecutor> mainAbortExecutor = 
super.abortExecutor();
+        if (auxiliaryFileWriters.isEmpty()) {
+            return mainAbortExecutor;
+        }
+
+        List<FileWriterAbortExecutor> abortExecutors = new ArrayList<>();
+        mainAbortExecutor.ifPresent(abortExecutors::add);
+        for (DataFileAuxiliaryWriter auxiliaryFileWriter : 
auxiliaryFileWriters) {
+            auxiliaryFileWriter.abortExecutor().ifPresent(abortExecutors::add);
+        }
+        if (abortExecutors.isEmpty()) {
+            return Optional.empty();
+        }
+        if (abortExecutors.size() == 1) {
+            return Optional.of(abortExecutors.get(0));
+        }
+        return Optional.of(new CompoundFileWriterAbortExecutor(fileIO, path, 
abortExecutors));
+    }
+
     @Override
     public DataFileMeta result() throws IOException {
         long fileSize = outputBytes();
         Pair<List<String>, SimpleStats> statsPair =
                 statsArraySerializer.toBinary(fieldStats(fileSize));
-        DataFileIndexWriter.FileIndexResult indexResult =
-                dataFileIndexWriter == null
-                        ? DataFileIndexWriter.EMPTY_RESULT
-                        : dataFileIndexWriter.result();
+        List<String> extraFiles = new ArrayList<>();
+        byte[] embeddedIndex = null;
+        for (DataFileAuxiliaryWriter auxiliaryFileWriter : 
auxiliaryFileWriters) {
+            DataFileAuxiliaryResult auxiliaryResult = 
auxiliaryFileWriter.result();
+            extraFiles.addAll(auxiliaryResult.extraFiles());
+            if (auxiliaryResult.embeddedIndexBytes() != null) {
+                if (embeddedIndex != null) {
+                    throw new IOException("Found more than one embedded index 
for one data file.");
+                }
+                embeddedIndex = auxiliaryResult.embeddedIndexBytes();
+            }
+        }
         String externalPath = isExternalPath ? path.toString() : null;
         return DataFileMeta.forAppend(
                 path.getName(),
@@ -116,14 +214,140 @@ public class RowDataFileWriter extends 
StatsCollectingSingleFileWriter<InternalR
                 sequenceNumberTracker.min(),
                 sequenceNumberTracker.max(),
                 schemaId,
-                indexResult.independentIndexFile() == null
-                        ? Collections.emptyList()
-                        : 
Collections.singletonList(indexResult.independentIndexFile()),
-                indexResult.embeddedIndexBytes(),
+                extraFiles.isEmpty() ? Collections.emptyList() : extraFiles,
+                embeddedIndex,
                 fileSource,
                 statsPair.getKey(),
                 externalPath,
                 null,
                 writeCols);
     }
+
+    private interface DataFileAuxiliaryWriter {
+
+        void write(InternalRow row) throws IOException;
+
+        void close() throws IOException;
+
+        void abort();
+
+        Optional<FileWriterAbortExecutor> abortExecutor();
+
+        DataFileAuxiliaryResult result() throws IOException;
+    }
+
+    private static class DataFileAuxiliaryResult {
+
+        private static final DataFileAuxiliaryResult EMPTY =
+                new DataFileAuxiliaryResult(null, Collections.emptyList());
+
+        @Nullable private final byte[] embeddedIndexBytes;
+        private final List<String> extraFiles;
+
+        private DataFileAuxiliaryResult(
+                @Nullable byte[] embeddedIndexBytes, List<String> extraFiles) {
+            this.embeddedIndexBytes = embeddedIndexBytes;
+            this.extraFiles = extraFiles;
+        }
+
+        @Nullable
+        private byte[] embeddedIndexBytes() {
+            return embeddedIndexBytes;
+        }
+
+        private List<String> extraFiles() {
+            return extraFiles;
+        }
+
+        private static DataFileAuxiliaryResult embeddedIndex(byte[] 
embeddedIndexBytes) {
+            return new DataFileAuxiliaryResult(embeddedIndexBytes, 
Collections.emptyList());
+        }
+
+        private static DataFileAuxiliaryResult extraFile(String extraFile) {
+            return new DataFileAuxiliaryResult(null, 
Collections.singletonList(extraFile));
+        }
+    }
+
+    private static class DataFileIndexAuxiliaryWriter implements 
DataFileAuxiliaryWriter {
+
+        private final DataFileIndexWriter writer;
+        private final FileIO fileIO;
+        private final Path indexPath;
+
+        private DataFileIndexAuxiliaryWriter(
+                DataFileIndexWriter writer, FileIO fileIO, Path indexPath) {
+            this.writer = writer;
+            this.fileIO = fileIO;
+            this.indexPath = indexPath;
+        }
+
+        @Override
+        public void write(InternalRow row) {
+            writer.write(row);
+        }
+
+        @Override
+        public void close() throws IOException {
+            writer.close();
+        }
+
+        @Override
+        public void abort() {
+            fileIO.deleteQuietly(indexPath);
+        }
+
+        @Override
+        public Optional<FileWriterAbortExecutor> abortExecutor() {
+            return Optional.of(new FileWriterAbortExecutor(fileIO, indexPath));
+        }
+
+        @Override
+        public DataFileAuxiliaryResult result() {
+            DataFileIndexWriter.FileIndexResult result = writer.result();
+            if (result.independentIndexFile() != null) {
+                return 
DataFileAuxiliaryResult.extraFile(result.independentIndexFile());
+            }
+            if (result.embeddedIndexBytes() != null) {
+                return 
DataFileAuxiliaryResult.embeddedIndex(result.embeddedIndexBytes());
+            }
+            return DataFileAuxiliaryResult.EMPTY;
+        }
+    }
+
+    private static class RowSidecarAuxiliaryWriter
+            extends SingleFileWriter<InternalRow, DataFileAuxiliaryResult>
+            implements DataFileAuxiliaryWriter {
+
+        private RowSidecarAuxiliaryWriter(
+                FileIO fileIO,
+                FormatWriterFactory factory,
+                Path path,
+                String compression,
+                boolean asyncWrite) {
+            super(fileIO, factory, path, Function.identity(), compression, 
asyncWrite);
+        }
+
+        @Override
+        public DataFileAuxiliaryResult result() {
+            return DataFileAuxiliaryResult.extraFile(path.getName());
+        }
+    }
+
+    private static class CompoundFileWriterAbortExecutor extends 
FileWriterAbortExecutor {
+
+        private final List<FileWriterAbortExecutor> abortExecutors;
+
+        private CompoundFileWriterAbortExecutor(
+                FileIO fileIO, Path path, List<FileWriterAbortExecutor> 
abortExecutors) {
+            super(fileIO, path);
+            this.abortExecutors = abortExecutors;
+        }
+
+        @Override
+        public void abort() {
+            for (FileWriterAbortExecutor abortExecutor : abortExecutors) {
+                abortExecutor.abort();
+            }
+        }
+    }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/io/RowDataRollingFileWriter.java 
b/paimon-core/src/main/java/org/apache/paimon/io/RowDataRollingFileWriter.java
index 81e0a0aefc..0da1badf62 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/io/RowDataRollingFileWriter.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/io/RowDataRollingFileWriter.java
@@ -22,6 +22,7 @@ import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.fileindex.FileIndexOptions;
 import org.apache.paimon.format.FileFormat;
 import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
 import org.apache.paimon.manifest.FileSource;
 import org.apache.paimon.statistics.SimpleColStatsCollector;
 import org.apache.paimon.types.RowType;
@@ -49,23 +50,32 @@ public class RowDataRollingFileWriter extends 
RollingFileWriterImpl<InternalRow,
             FileSource fileSource,
             boolean asyncFileWrite,
             boolean statsDenseStore,
-            @Nullable List<String> writeCols) {
+            @Nullable List<String> writeCols,
+            @Nullable FileFormat rowSidecarFormat) {
         super(
-                () ->
-                        new RowDataFileWriter(
-                                fileIO,
-                                RollingFileWriter.createFileWriterContext(
-                                        fileFormat, writeSchema, 
statsCollectors, fileCompression),
-                                pathFactory.newPath(),
-                                writeSchema,
-                                schemaId,
-                                seqNumCounterSupplier,
-                                fileIndexOptions,
-                                fileSource,
-                                asyncFileWrite,
-                                statsDenseStore,
-                                pathFactory.isExternalPath(),
-                                writeCols),
+                () -> {
+                    Path dataPath = pathFactory.newPath();
+                    Path rowSidecarPath =
+                            rowSidecarFormat == null
+                                    ? null
+                                    : new Path(dataPath.getParent(), 
dataPath.getName() + ".row");
+                    return new RowDataFileWriter(
+                            fileIO,
+                            RollingFileWriter.createFileWriterContext(
+                                    fileFormat, writeSchema, statsCollectors, 
fileCompression),
+                            dataPath,
+                            writeSchema,
+                            schemaId,
+                            seqNumCounterSupplier,
+                            fileIndexOptions,
+                            fileSource,
+                            asyncFileWrite,
+                            statsDenseStore,
+                            pathFactory.isExternalPath(),
+                            writeCols,
+                            rowSidecarFormat,
+                            rowSidecarPath);
+                },
                 targetFileSize);
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/BaseAppendFileStoreWrite.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/BaseAppendFileStoreWrite.java
index b40e8ee2bc..574143192e 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/BaseAppendFileStoreWrite.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/BaseAppendFileStoreWrite.java
@@ -155,6 +155,7 @@ public abstract class BaseAppendFileStoreWrite extends 
MemoryFileStoreWrite<Inte
                 options.asyncFileWrite(),
                 options.statsDenseStore(),
                 options.dataEvolutionEnabled(),
+                rowSidecarFileFormat(),
                 blobContext);
     }
 
@@ -278,7 +279,15 @@ public abstract class BaseAppendFileStoreWrite extends 
MemoryFileStoreWrite<Inte
                 FileSource.COMPACT,
                 options.asyncFileWrite(),
                 options.statsDenseStore(),
-                rowType.equals(writeType) ? null : writeType.getFieldNames());
+                rowType.equals(writeType) ? null : writeType.getFieldNames(),
+                rowSidecarFileFormat());
+    }
+
+    @Nullable
+    private FileFormat rowSidecarFileFormat() {
+        return options.dataEvolutionEnabled() && 
options.dataEvolutionRowSidecarEnabled()
+                ? FileFormat.fromIdentifier("row", options.toConfiguration())
+                : null;
     }
 
     private RecordReaderIterator<InternalRow> createFilesIterator(
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java
index 145fdc9ad4..31ff6cd614 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java
@@ -28,6 +28,7 @@ import org.apache.paimon.format.FileFormatDiscover;
 import org.apache.paimon.format.FormatKey;
 import org.apache.paimon.format.FormatReaderContext;
 import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
 import org.apache.paimon.globalindex.IndexedSplit;
 import org.apache.paimon.globalindex.IndexedSplitRecordReader;
 import org.apache.paimon.io.DataFileMeta;
@@ -89,6 +90,8 @@ import static 
org.apache.paimon.utils.Preconditions.checkNotNull;
  */
 public class DataEvolutionSplitRead implements SplitRead<InternalRow> {
 
+    private static final String ROW_SIDECAR_FORMAT = "row";
+
     private final FileIO fileIO;
     private final TableSchema schema;
     private final FileFormatDiscover formatDiscover;
@@ -261,7 +264,8 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
         for (int i = 0; i < fieldsFiles.size(); i++) {
             FieldBunch bunch = fieldsFiles.get(i);
             DataFileMeta firstFile = bunch.files().get(0);
-            String formatIdentifier = 
DataFilePathFactory.formatIdentifier(firstFile.fileName());
+            FileReadTarget readTarget = readTarget(firstFile, 
dataFilePathFactory, rowRanges);
+            String formatIdentifier = readTarget.formatIdentifier;
             long schemaId = firstFile.schemaId();
             TableSchema dataSchema = 
schemaFetcher.apply(schemaId).project(firstFile.writeCols());
             int[] fieldIds =
@@ -434,7 +438,8 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
             List<Range> rowRanges,
             RowType readRowType)
             throws IOException {
-        String formatIdentifier = 
DataFilePathFactory.formatIdentifier(file.fileName());
+        FileReadTarget readTarget = readTarget(file, dataFilePathFactory, 
rowRanges);
+        String formatIdentifier = readTarget.formatIdentifier;
         long schemaId = file.schemaId();
         FormatReaderMapping formatReaderMapping =
                 formatReaderMappings.computeIfAbsent(
@@ -447,7 +452,7 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
                                                 ? schema
                                                 : 
schemaFetcher.apply(schemaId)));
         return createFileReader(
-                partition, file, dataFilePathFactory, formatReaderMapping, 
rowRanges, readRowType);
+                partition, file, formatReaderMapping, rowRanges, readRowType, 
readTarget);
     }
 
     private FileRecordReader<InternalRow> createFileReader(
@@ -458,10 +463,26 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
             List<Range> rowRanges,
             RowType readRowType)
             throws IOException {
+        return createFileReader(
+                partition,
+                file,
+                formatReaderMapping,
+                rowRanges,
+                readRowType,
+                readTarget(file, dataFilePathFactory, rowRanges));
+    }
+
+    private FileRecordReader<InternalRow> createFileReader(
+            BinaryRow partition,
+            DataFileMeta file,
+            FormatReaderMapping formatReaderMapping,
+            List<Range> rowRanges,
+            RowType readRowType,
+            FileReadTarget readTarget)
+            throws IOException {
         RoaringBitmap32 selection = file.toFileSelection(rowRanges);
         FormatReaderContext formatReaderContext =
-                new FormatReaderContext(
-                        fileIO, dataFilePathFactory.toPath(file), 
file.fileSize(), selection);
+                new FormatReaderContext(fileIO, readTarget.path, 
readTarget.fileSize, selection);
         return new DataFileRecordReader(
                 readRowType,
                 formatReaderMapping.getReaderFactory(),
@@ -477,6 +498,111 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
                 formatReaderMapping.getSystemFields());
     }
 
+    private FileReadTarget readTarget(
+            DataFileMeta file, DataFilePathFactory dataFilePathFactory, 
List<Range> rowRanges)
+            throws IOException {
+        String rowSidecar = rowSidecarFileName(file);
+        if (rowSidecar != null
+                && shouldReadRowSidecar(
+                        file,
+                        rowRanges,
+                        coreOptions.dataEvolutionRowSidecarMaxSelectedRows(),
+                        
coreOptions.dataEvolutionRowSidecarMaxSelectionRatio())) {
+            Path rowPath = dataFilePathFactory.toAlignedPath(rowSidecar, file);
+            try {
+                return new FileReadTarget(
+                        ROW_SIDECAR_FORMAT, rowPath, 
fileIO.getFileStatus(rowPath).getLen());
+            } catch (IOException e) {
+                if (!coreOptions.scanIgnoreLostFile()) {
+                    throw e;
+                }
+            }
+        }
+        return new FileReadTarget(
+                DataFilePathFactory.formatIdentifier(file.fileName()),
+                dataFilePathFactory.toPath(file),
+                file.fileSize());
+    }
+
+    @Nullable
+    @VisibleForTesting
+    static String rowSidecarFileName(DataFileMeta file) {
+        List<String> rowFiles =
+                file.extraFiles().stream()
+                        .filter(DataEvolutionSplitRead::isRowSidecarFile)
+                        .collect(Collectors.toList());
+        return rowFiles.size() == 1 ? rowFiles.get(0) : null;
+    }
+
+    @VisibleForTesting
+    static boolean shouldReadRowSidecar(DataFileMeta file, @Nullable 
List<Range> rowRanges) {
+        return shouldReadRowSidecar(
+                file,
+                rowRanges,
+                
CoreOptions.DATA_EVOLUTION_ROW_SIDECAR_MAX_SELECTED_ROWS.defaultValue(),
+                
CoreOptions.DATA_EVOLUTION_ROW_SIDECAR_MAX_SELECTION_RATIO.defaultValue());
+    }
+
+    @VisibleForTesting
+    static boolean shouldReadRowSidecar(
+            DataFileMeta file,
+            @Nullable List<Range> rowRanges,
+            long maxSelectedRows,
+            double maxSelectionRatio) {
+        if (rowRanges == null
+                || rowRanges.isEmpty()
+                || file.rowCount() <= 0
+                || isBlobFile(file.fileName())
+                || isVectorStoreFile(file.fileName())
+                || rowSidecarFileName(file) == null) {
+            return false;
+        }
+
+        long selectedRowCount = selectedRowCount(file, rowRanges);
+        if (selectedRowCount <= 0 || selectedRowCount >= file.rowCount()) {
+            return false;
+        }
+
+        double selectionRatio = (double) selectedRowCount / file.rowCount();
+        return selectedRowCount <= maxSelectedRows && selectionRatio <= 
maxSelectionRatio;
+    }
+
+    @VisibleForTesting
+    static long selectedRowCount(DataFileMeta file, List<Range> rowRanges) {
+        Range fileRange = file.nonNullRowIdRange();
+        List<Range> intersections = new ArrayList<>();
+        for (Range range : rowRanges) {
+            Range intersection = Range.intersection(fileRange, range);
+            if (intersection != null) {
+                intersections.add(intersection);
+            }
+        }
+        return Range.sortAndMergeOverlap(intersections, true).stream()
+                .mapToLong(Range::count)
+                .sum();
+    }
+
+    private static boolean isRowSidecarFile(String fileName) {
+        try {
+            return 
ROW_SIDECAR_FORMAT.equals(DataFilePathFactory.formatIdentifier(fileName));
+        } catch (RuntimeException e) {
+            return false;
+        }
+    }
+
+    private static class FileReadTarget {
+
+        private final String formatIdentifier;
+        private final Path path;
+        private final long fileSize;
+
+        private FileReadTarget(String formatIdentifier, Path path, long 
fileSize) {
+            this.formatIdentifier = formatIdentifier;
+            this.path = path;
+            this.fileSize = fileSize;
+        }
+    }
+
     @VisibleForTesting
     public static List<FieldBunch> splitFieldBunches(
             List<DataFileMeta> needMergeFiles, Function<DataFileMeta, RowType> 
fileToRowType) {
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/AppendOnlyWriterTest.java 
b/paimon-core/src/test/java/org/apache/paimon/append/AppendOnlyWriterTest.java
index c861296f2b..55965234bb 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/append/AppendOnlyWriterTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/append/AppendOnlyWriterTest.java
@@ -827,6 +827,7 @@ public class AppendOnlyWriterTest {
                         true,
                         false,
                         options.dataEvolutionEnabled(),
+                        null,
                         BlobFileContext.create(writeSchema, options));
         writer.setMemoryPool(
                 new HeapMemorySegmentPool(options.writeBufferSize(), 
options.pageSize()));
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/DedicatedFormatRollingFileWriterTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/append/DedicatedFormatRollingFileWriterTest.java
index ffd6baedb6..0bff7e43b5 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/append/DedicatedFormatRollingFileWriterTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/append/DedicatedFormatRollingFileWriterTest.java
@@ -147,6 +147,31 @@ public class DedicatedFormatRollingFileWriterTest {
                         metasResult.subList(1, 
4).stream().mapToLong(DataFileMeta::rowCount).sum());
     }
 
+    @Test
+    public void testDoesNotWriteRowSidecar() throws IOException {
+        // Tests that: dedicated blob files do not create row-store sidecars.
+        // Kills mutation: adding row sidecar writing to 
DedicatedFormatRollingFileWriter.
+        writer.write(GenericRow.of(1, BinaryString.fromString("test"), new 
BlobData(testBlobData)));
+        writer.close();
+
+        List<DataFileMeta> metasResult = writer.result();
+        assertThat(metasResult).hasSize(2);
+        assertThat(metasResult).anyMatch(file -> 
"parquet".equals(file.fileFormat()));
+        assertThat(metasResult).anyMatch(file -> 
"blob".equals(file.fileFormat()));
+        assertThat(metasResult)
+                .allSatisfy(
+                        file ->
+                                assertThat(file.extraFiles())
+                                        .noneMatch(extraFile -> 
extraFile.endsWith(".row")));
+        try (Stream<java.nio.file.Path> files = Files.walk(tempDir)) {
+            assertThat(
+                            files.filter(Files::isRegularFile)
+                                    .noneMatch(
+                                            file -> 
file.getFileName().toString().endsWith(".row")))
+                    .isTrue();
+        }
+    }
+
     @Test
     public void testBundleWritingPreservesMainFileIndexSideEffects() throws 
IOException {
         Options options = new Options();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/io/KeyValueFileReadWriteTest.java 
b/paimon-core/src/test/java/org/apache/paimon/io/KeyValueFileReadWriteTest.java
index 0169a6d5e3..d295ed575e 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/io/KeyValueFileReadWriteTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/io/KeyValueFileReadWriteTest.java
@@ -295,6 +295,7 @@ public class KeyValueFileReadWriteTest {
                         true,
                         false,
                         options.dataEvolutionEnabled(),
+                        null,
                         BlobFileContext.create(schema, options));
         appendOnlyWriter.setMemoryPool(
                 new HeapMemorySegmentPool(options.writeBufferSize(), 
options.pageSize()));
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/io/RollingFileWriterTest.java 
b/paimon-core/src/test/java/org/apache/paimon/io/RollingFileWriterTest.java
index 84e7c6766b..ec334007e5 100644
--- a/paimon-core/src/test/java/org/apache/paimon/io/RollingFileWriterTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/io/RollingFileWriterTest.java
@@ -23,10 +23,12 @@ import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.fileindex.FileIndexOptions;
 import org.apache.paimon.format.FileFormat;
+import org.apache.paimon.format.FormatReaderContext;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.local.LocalFileIO;
 import org.apache.paimon.manifest.FileSource;
 import org.apache.paimon.options.Options;
+import org.apache.paimon.reader.RecordReader;
 import org.apache.paimon.statistics.SimpleColStatsCollector;
 import org.apache.paimon.types.DataType;
 import org.apache.paimon.types.IntType;
@@ -41,6 +43,11 @@ import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.File;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -122,6 +129,119 @@ public class RollingFileWriterTest {
         }
     }
 
+    @Test
+    public void testWriteRowSidecar() throws IOException {
+        FileFormat fileFormat = FileFormat.fromIdentifier("parquet", new 
Options());
+        FileFormat rowFormat = FileFormat.fromIdentifier("row", new Options());
+        DataFilePathFactory pathFactory =
+                new DataFilePathFactory(
+                        new Path(tempDir + "/bucket-0"),
+                        CoreOptions.FILE_FORMAT.defaultValue().toString(),
+                        CoreOptions.DATA_FILE_PREFIX.defaultValue(),
+                        CoreOptions.CHANGELOG_FILE_PREFIX.defaultValue(),
+                        
CoreOptions.FILE_SUFFIX_INCLUDE_COMPRESSION.defaultValue(),
+                        CoreOptions.FILE_COMPRESSION.defaultValue(),
+                        null);
+        RowDataRollingFileWriter writer =
+                new RowDataRollingFileWriter(
+                        LocalFileIO.create(),
+                        0L,
+                        fileFormat,
+                        TARGET_FILE_SIZE,
+                        SCHEMA,
+                        pathFactory,
+                        () -> new LongCounter(0),
+                        CoreOptions.FILE_COMPRESSION.defaultValue(),
+                        
SimpleColStatsCollector.createFullStatsFactories(SCHEMA.getFieldCount()),
+                        new FileIndexOptions(),
+                        FileSource.APPEND,
+                        true,
+                        false,
+                        null,
+                        rowFormat);
+
+        writer.write(GenericRow.of(1));
+        writer.close();
+
+        List<DataFileMeta> files = writer.result();
+        assertThat(files).hasSize(1);
+        DataFileMeta file = files.get(0);
+        assertThat(file.extraFiles()).hasSize(1);
+        String rowSidecar = file.extraFiles().get(0);
+        assertThat(rowSidecar).endsWith(".row");
+        Path dataPath = pathFactory.toPath(file);
+        Path rowSidecarPath = pathFactory.toAlignedPath(rowSidecar, file);
+        assertThat(LocalFileIO.create().exists(dataPath)).isTrue();
+        assertThat(LocalFileIO.create().exists(rowSidecarPath)).isTrue();
+
+        writer.abort();
+        assertThat(LocalFileIO.create().exists(dataPath)).isFalse();
+        assertThat(LocalFileIO.create().exists(rowSidecarPath)).isFalse();
+    }
+
+    @Test
+    public void testWriteRowSidecarWithBundle() throws IOException {
+        FileFormat fileFormat = FileFormat.fromIdentifier("parquet", new 
Options());
+        FileFormat rowFormat = FileFormat.fromIdentifier("row", new Options());
+        DataFilePathFactory pathFactory =
+                new DataFilePathFactory(
+                        new Path(tempDir + "/bucket-0"),
+                        CoreOptions.FILE_FORMAT.defaultValue().toString(),
+                        CoreOptions.DATA_FILE_PREFIX.defaultValue(),
+                        CoreOptions.CHANGELOG_FILE_PREFIX.defaultValue(),
+                        
CoreOptions.FILE_SUFFIX_INCLUDE_COMPRESSION.defaultValue(),
+                        CoreOptions.FILE_COMPRESSION.defaultValue(),
+                        null);
+        RowDataRollingFileWriter writer =
+                new RowDataRollingFileWriter(
+                        LocalFileIO.create(),
+                        0L,
+                        fileFormat,
+                        TARGET_FILE_SIZE,
+                        SCHEMA,
+                        pathFactory,
+                        () -> new LongCounter(0),
+                        CoreOptions.FILE_COMPRESSION.defaultValue(),
+                        
SimpleColStatsCollector.createFullStatsFactories(SCHEMA.getFieldCount()),
+                        new FileIndexOptions(),
+                        FileSource.APPEND,
+                        true,
+                        false,
+                        null,
+                        rowFormat);
+
+        writer.writeBundle(
+                new SingleUseBundleRecords(Arrays.asList(GenericRow.of(1), 
GenericRow.of(2))));
+        writer.close();
+
+        List<DataFileMeta> files = writer.result();
+        assertThat(files).hasSize(1);
+        DataFileMeta file = files.get(0);
+        assertThat(file.rowCount()).isEqualTo(2);
+        assertThat(file.minSequenceNumber()).isEqualTo(0);
+        assertThat(file.maxSequenceNumber()).isEqualTo(1);
+        assertThat(file.extraFiles()).hasSize(1);
+        assertThat(
+                        readIntsFromRowFile(
+                                rowFormat,
+                                
pathFactory.toAlignedPath(file.extraFiles().get(0), file)))
+                .containsExactly(1, 2);
+    }
+
+    private static List<Integer> readIntsFromRowFile(FileFormat rowFormat, 
Path path)
+            throws IOException {
+        LocalFileIO fileIO = LocalFileIO.create();
+        List<Integer> result = new ArrayList<>();
+        try (RecordReader<InternalRow> reader =
+                rowFormat
+                        .createReaderFactory(SCHEMA, SCHEMA, 
Collections.emptyList())
+                        .createReader(
+                                new FormatReaderContext(fileIO, path, 
fileIO.getFileSize(path)))) {
+            reader.forEachRemaining(row -> result.add(row.getInt(0)));
+        }
+        return result;
+    }
+
     private void assertFileNum(int expected) {
         File dataDir = tempDir.resolve("bucket-0").toFile();
         File[] files = dataDir.listFiles();
@@ -139,4 +259,28 @@ public class RollingFileWriterTest {
         assertThat(file.valueStatsCols()).isNull();
         
assertThat(file.valueStats().minValues().getFieldCount()).isEqualTo(SCHEMA.getFieldCount());
     }
+
+    private static class SingleUseBundleRecords implements BundleRecords {
+
+        private final List<InternalRow> rows;
+        private boolean iterated;
+
+        private SingleUseBundleRecords(List<InternalRow> rows) {
+            this.rows = rows;
+        }
+
+        @Override
+        public Iterator<InternalRow> iterator() {
+            if (iterated) {
+                throw new IllegalStateException("Bundle should only be 
consumed once.");
+            }
+            iterated = true;
+            return rows.iterator();
+        }
+
+        @Override
+        public long rowCount() {
+            return rows.size();
+        }
+    }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionSplitReadTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionSplitReadTest.java
index 95bae45a7d..89316c249b 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionSplitReadTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionSplitReadTest.java
@@ -18,11 +18,34 @@
 
 package org.apache.paimon.operation;
 
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.format.FileFormat;
+import org.apache.paimon.format.FormatWriter;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.globalindex.IndexedSplit;
 import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.FileStorePathFactory;
+import org.apache.paimon.utils.Range;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 
+import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
@@ -30,9 +53,14 @@ import java.util.List;
 import static org.apache.paimon.data.BinaryRow.EMPTY_ROW;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class DataEvolutionSplitReadTest {
 
+    @TempDir java.nio.file.Path tempDir;
+
     @Test
     public void testDifferentRowIdRange() {
         DataFileMeta file1 = createFile("file1.parquet", 1L, 100, 10);
@@ -133,25 +161,206 @@ class DataEvolutionSplitReadTest {
         assertEquals(Arrays.asList(file4, file5, file6), result.get(1));
     }
 
+    @Test
+    public void testRowSidecarFileName() {
+        DataFileMeta file =
+                createFile(
+                        "file1.parquet",
+                        1L,
+                        100,
+                        1,
+                        Arrays.asList("file1.parquet.index", "file1.row"));
+
+        assertEquals("file1.row", 
DataEvolutionSplitRead.rowSidecarFileName(file));
+    }
+
+    @Test
+    public void testRowSidecarFileNameWithNoOrAmbiguousSidecar() {
+        DataFileMeta noSidecar =
+                createFile(
+                        "file1.parquet",
+                        1L,
+                        100,
+                        1,
+                        Arrays.asList("file1.parquet.index", "lookup.sst"));
+        DataFileMeta ambiguousSidecar =
+                createFile(
+                        "file1.parquet", 1L, 100, 1, 
Arrays.asList("file1.row", "file1-copy.row"));
+
+        assertNull(DataEvolutionSplitRead.rowSidecarFileName(noSidecar));
+        
assertNull(DataEvolutionSplitRead.rowSidecarFileName(ambiguousSidecar));
+    }
+
+    @Test
+    public void testShouldReadRowSidecarForSparseRowSelection() {
+        DataFileMeta file =
+                createFile("file1.parquet", 10L, 100, 1, 
Collections.singletonList("file1.row"));
+
+        assertTrue(
+                DataEvolutionSplitRead.shouldReadRowSidecar(
+                        file, Arrays.asList(new Range(10L, 10L), new 
Range(42L, 42L))));
+    }
+
+    @Test
+    public void testShouldReadRowSidecarRequiresSmallCountAndLowRatio() {
+        DataFileMeta smallFile =
+                createFile("file1.parquet", 10L, 100, 1, 
Collections.singletonList("file1.row"));
+        DataFileMeta largeFile =
+                createFile(
+                        "file2.parquet", 10L, 1_000_000, 1, 
Collections.singletonList("file2.row"));
+
+        assertTrue(
+                DataEvolutionSplitRead.shouldReadRowSidecar(
+                        largeFile, Collections.singletonList(new Range(10L, 
4105L))));
+        assertFalse(
+                DataEvolutionSplitRead.shouldReadRowSidecar(
+                        smallFile, Collections.singletonList(new Range(10L, 
15L))));
+        assertFalse(
+                DataEvolutionSplitRead.shouldReadRowSidecar(
+                        largeFile, Collections.singletonList(new Range(10L, 
4106L))));
+        assertTrue(
+                DataEvolutionSplitRead.shouldReadRowSidecar(
+                        smallFile, Collections.singletonList(new Range(10L, 
15L)), 64L, 0.25d));
+    }
+
+    @Test
+    public void testShouldNotReadRowSidecarWithoutSparseSelection() {
+        DataFileMeta file =
+                createFile("file1.parquet", 10L, 100, 1, 
Collections.singletonList("file1.row"));
+
+        assertFalse(DataEvolutionSplitRead.shouldReadRowSidecar(file, null));
+        assertFalse(DataEvolutionSplitRead.shouldReadRowSidecar(file, 
Collections.emptyList()));
+        assertFalse(
+                DataEvolutionSplitRead.shouldReadRowSidecar(
+                        file, Collections.singletonList(new Range(10L, 
109L))));
+        assertFalse(
+                DataEvolutionSplitRead.shouldReadRowSidecar(
+                        createFile("file2.parquet", 10L, 100, 1),
+                        Collections.singletonList(new Range(10L, 10L))));
+        assertFalse(
+                DataEvolutionSplitRead.shouldReadRowSidecar(
+                        createFile(
+                                "file3.blob", 10L, 100, 1, 
Collections.singletonList("file3.row")),
+                        Collections.singletonList(new Range(10L, 10L))));
+    }
+
+    @Test
+    public void testSelectedRowCountMergesOverlappingRanges() {
+        DataFileMeta file = createFile("file1.parquet", 10L, 100, 1);
+
+        assertEquals(
+                12,
+                DataEvolutionSplitRead.selectedRowCount(
+                        file,
+                        Arrays.asList(
+                                new Range(5L, 12L), new Range(12L, 15L), new 
Range(20L, 25L))));
+    }
+
+    @Test
+    public void testSparseRowIdReadUsesRowSidecar() throws Exception {
+        LocalFileIO fileIO = new LocalFileIO();
+        Path tableRoot = new Path(tempDir.toUri().toString());
+        CoreOptions coreOptions = new CoreOptions(new Options());
+        FileStorePathFactory pathFactory =
+                new FileStorePathFactory(
+                        tableRoot,
+                        RowType.of(),
+                        coreOptions.partitionDefaultName(),
+                        CoreOptions.FILE_FORMAT.defaultValue(),
+                        CoreOptions.DATA_FILE_PREFIX.defaultValue(),
+                        CoreOptions.CHANGELOG_FILE_PREFIX.defaultValue(),
+                        
CoreOptions.PARTITION_GENERATE_LEGACY_NAME.defaultValue(),
+                        
CoreOptions.FILE_SUFFIX_INCLUDE_COMPRESSION.defaultValue(),
+                        CoreOptions.FILE_COMPRESSION.defaultValue(),
+                        null,
+                        null,
+                        CoreOptions.ExternalPathStrategy.NONE,
+                        null,
+                        false,
+                        null);
+
+        Schema schema =
+                Schema.newBuilder()
+                        .column("f0", DataTypes.INT())
+                        .column("f1", DataTypes.STRING())
+                        .build();
+        SchemaManager schemaManager = new SchemaManager(fileIO, tableRoot);
+        TableSchema tableSchema = schemaManager.createTable(schema);
+        RowType rowType = tableSchema.logicalRowType();
+
+        Path bucketPath = pathFactory.bucketPath(EMPTY_ROW, 0);
+        fileIO.mkdirs(bucketPath);
+        String rowSidecarName = "data-0.row";
+        writeRowFile(fileIO, new Path(bucketPath, rowSidecarName), rowType, 
100);
+
+        DataFileMeta dataFile =
+                createFile(
+                        "data-0.parquet", 10L, 100, 1, 
Collections.singletonList(rowSidecarName));
+        DataSplit dataSplit =
+                DataSplit.builder()
+                        .withPartition(EMPTY_ROW)
+                        .withBucket(0)
+                        .withBucketPath(bucketPath.toString())
+                        .withDataFiles(Collections.singletonList(dataFile))
+                        .rawConvertible(false)
+                        .build();
+
+        DataEvolutionSplitRead splitRead =
+                new DataEvolutionSplitRead(
+                        fileIO, schemaManager, tableSchema, rowType, 
coreOptions, pathFactory);
+        IndexedSplit indexedSplit =
+                new IndexedSplit(
+                        dataSplit, Arrays.asList(new Range(10L, 10L), new 
Range(42L, 42L)), null);
+
+        List<Integer> actual = new ArrayList<>();
+        try (RecordReader<InternalRow> reader = 
splitRead.createReader(indexedSplit)) {
+            reader.forEachRemaining(row -> actual.add(row.getInt(0)));
+        }
+
+        assertEquals(Arrays.asList(1000, 1032), actual);
+    }
+
+    private static void writeRowFile(LocalFileIO fileIO, Path path, RowType 
rowType, int rowCount)
+            throws IOException {
+        FileFormat format = FileFormat.fromIdentifier("row", new Options());
+        try (PositionOutputStream out = fileIO.newOutputStream(path, false)) {
+            FormatWriter writer = 
format.createWriterFactory(rowType).create(out, "zstd");
+            for (int i = 0; i < rowCount; i++) {
+                writer.addElement(GenericRow.of(1000 + i, 
BinaryString.fromString("row-" + i)));
+            }
+            writer.close();
+        }
+    }
+
     private static DataFileMeta createFile(
             String name, long firstRowId, long rowCount, long maxSequence) {
+        return createFile(name, firstRowId, rowCount, maxSequence, 
Collections.emptyList());
+    }
+
+    private static DataFileMeta createFile(
+            String name,
+            long firstRowId,
+            long rowCount,
+            long maxSequence,
+            List<String> extraFiles) {
         return DataFileMeta.create(
-                name,
-                10000L,
-                (int) rowCount,
-                EMPTY_ROW,
-                EMPTY_ROW,
-                null,
-                null,
-                0L,
-                maxSequence,
-                0,
-                0,
-                0L,
-                null,
-                FileSource.APPEND,
-                null,
-                firstRowId,
-                null);
+                        name,
+                        10000L,
+                        (int) rowCount,
+                        EMPTY_ROW,
+                        EMPTY_ROW,
+                        null,
+                        null,
+                        0L,
+                        maxSequence,
+                        0,
+                        0,
+                        0L,
+                        null,
+                        FileSource.APPEND,
+                        null,
+                        firstRowId,
+                        null)
+                .copy(extraFiles);
     }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java 
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
index 0b1799ac81..89742874c1 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
@@ -31,6 +31,7 @@ import org.apache.paimon.globalindex.IndexedSplit;
 import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.index.IndexPathFactory;
 import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataFilePathFactory;
 import org.apache.paimon.manifest.ManifestEntry;
 import org.apache.paimon.manifest.ManifestFileMeta;
 import org.apache.paimon.predicate.Predicate;
@@ -43,6 +44,7 @@ import org.apache.paimon.table.sink.BatchTableCommit;
 import org.apache.paimon.table.sink.BatchTableWrite;
 import org.apache.paimon.table.sink.BatchWriteBuilder;
 import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
 import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.EndOfScanException;
 import org.apache.paimon.table.source.ReadBuilder;
@@ -72,6 +74,56 @@ import static 
org.assertj.core.api.AssertionsForClassTypes.assertThat;
 /** Test for table with data evolution. */
 public class DataEvolutionTableTest extends DataEvolutionTestBase {
 
+    @Test
+    public void testRowSidecarDisabledByDefault() throws Exception {
+        createTableDefault();
+        List<DataFileMeta> newFiles = 
writeOneFullRowAndCollectNewFiles(getTableDefault());
+
+        
assertThat(newFiles.stream().anyMatch(DataEvolutionTableTest::containsRowSidecar))
+                .isFalse();
+    }
+
+    @Test
+    public void testRowSidecarEnabledByTableOption() throws Exception {
+        Schema.Builder schemaBuilder = Schema.newBuilder();
+        schemaBuilder.column("f0", DataTypes.INT());
+        schemaBuilder.column("f1", DataTypes.STRING());
+        schemaBuilder.column("f2", DataTypes.STRING());
+        schemaBuilder.option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        schemaBuilder.option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        
schemaBuilder.option(CoreOptions.DATA_EVOLUTION_ROW_SIDECAR_ENABLED.key(), 
"true");
+        Schema schema = schemaBuilder.build();
+        catalog.createTable(identifier(), schema, true);
+
+        FileStoreTable table = getTableDefault();
+        List<CommitMessage> messages;
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = 
builder.newWrite().withWriteType(schema.rowType())) {
+            write.write(
+                    GenericRow.of(1, BinaryString.fromString("a"), 
BinaryString.fromString("b")));
+            messages = write.prepareCommit();
+            builder.newCommit().commit(messages);
+        }
+
+        List<DataFileMeta> newFiles = newFiles(messages);
+        
assertThat(newFiles.stream().anyMatch(DataEvolutionTableTest::containsRowSidecar)).isTrue();
+        for (CommitMessage message : messages) {
+            CommitMessageImpl commitMessage = (CommitMessageImpl) message;
+            DataFilePathFactory dataFilePathFactory =
+                    table.store()
+                            .pathFactory()
+                            .createDataFilePathFactory(message.partition(), 
message.bucket());
+            for (DataFileMeta file : 
commitMessage.newFilesIncrement().newFiles()) {
+                for (String extraFile : file.extraFiles()) {
+                    if (extraFile.endsWith(".row")) {
+                        table.fileIO()
+                                
.getFileStatus(dataFilePathFactory.toAlignedPath(extraFile, file));
+                    }
+                }
+            }
+        }
+    }
+
     @Test
     public void testBasic() throws Exception {
         createTableDefault();
@@ -1892,6 +1944,32 @@ public class DataEvolutionTableTest extends 
DataEvolutionTestBase {
         assertThat(plannedFileCount(table, RowType.of(SpecialFields.ROW_ID), 
null)).isEqualTo(2);
     }
 
+    private List<DataFileMeta> 
writeOneFullRowAndCollectNewFiles(FileStoreTable table)
+            throws Exception {
+        Schema schema = schemaDefault();
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = 
builder.newWrite().withWriteType(schema.rowType())) {
+            write.write(
+                    GenericRow.of(1, BinaryString.fromString("a"), 
BinaryString.fromString("b")));
+            List<CommitMessage> messages = write.prepareCommit();
+            builder.newCommit().commit(messages);
+            return newFiles(messages);
+        }
+    }
+
+    private static List<DataFileMeta> newFiles(List<CommitMessage> messages) {
+        return messages.stream()
+                .flatMap(
+                        message ->
+                                ((CommitMessageImpl) message)
+                                        
.newFilesIncrement().newFiles().stream())
+                .collect(Collectors.toList());
+    }
+
+    private static boolean containsRowSidecar(DataFileMeta file) {
+        return file.extraFiles().stream().anyMatch(extraFile -> 
extraFile.endsWith(".row"));
+    }
+
     private static int plannedFileCount(FileStoreTable table, RowType 
readType, Predicate filter) {
         ReadBuilder rb = table.newReadBuilder();
         if (readType != null) {
diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index 2b24bc1be4..abd2bac190 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -581,6 +581,33 @@ class CoreOptions:
         .default_value(False)
         .with_description("Whether to enable data evolution.")
     )
+
+    DATA_EVOLUTION_ROW_SIDECAR_ENABLED: ConfigOption[bool] = (
+        ConfigOptions.key("data-evolution.row-sidecar.enabled")
+        .boolean_type()
+        .default_value(False)
+        .with_description(
+            "Whether to generate row-store sidecar files for normal data files 
on data evolution tables."
+        )
+    )
+
+    DATA_EVOLUTION_ROW_SIDECAR_MAX_SELECTED_ROWS: ConfigOption[int] = (
+        ConfigOptions.key("data-evolution.row-sidecar.max-selected-rows")
+        .long_type()
+        .default_value(4096)
+        .with_description(
+            "Maximum selected row count for reading a row-store sidecar file."
+        )
+    )
+
+    DATA_EVOLUTION_ROW_SIDECAR_MAX_SELECTION_RATIO: ConfigOption[float] = (
+        ConfigOptions.key("data-evolution.row-sidecar.max-selection-ratio")
+        .double_type()
+        .default_value(0.05)
+        .with_description(
+            "Maximum selected row ratio for reading a row-store sidecar file."
+        )
+    )
     # External paths options
     DATA_FILE_EXTERNAL_PATHS: ConfigOption[str] = (
         ConfigOptions.key("data-file.external-paths")
@@ -1021,6 +1048,25 @@ class CoreOptions:
     def data_evolution_enabled(self, default=None):
         return self.options.get(CoreOptions.DATA_EVOLUTION_ENABLED, default)
 
+    def data_evolution_row_sidecar_enabled(self, default=None):
+        return 
self.options.get(CoreOptions.DATA_EVOLUTION_ROW_SIDECAR_ENABLED, default)
+
+    def data_evolution_row_sidecar_max_selected_rows(self, default=None):
+        max_selected_rows = self.options.get(
+            CoreOptions.DATA_EVOLUTION_ROW_SIDECAR_MAX_SELECTED_ROWS, default)
+        if max_selected_rows <= 0:
+            raise ValueError(
+                "data-evolution.row-sidecar.max-selected-rows must be greater 
than 0.")
+        return max_selected_rows
+
+    def data_evolution_row_sidecar_max_selection_ratio(self, default=None):
+        max_selection_ratio = self.options.get(
+            CoreOptions.DATA_EVOLUTION_ROW_SIDECAR_MAX_SELECTION_RATIO, 
default)
+        if max_selection_ratio <= 0 or max_selection_ratio > 1:
+            raise ValueError(
+                "data-evolution.row-sidecar.max-selection-ratio must be in (0, 
1].")
+        return max_selection_ratio
+
     def global_index_column_update_action(self, default=None):
         return self.options.get(CoreOptions.GLOBAL_INDEX_COLUMN_UPDATE_ACTION, 
default)
 
diff --git a/paimon-python/pypaimon/read/reader/format_row_reader.py 
b/paimon-python/pypaimon/read/reader/format_row_reader.py
index 34a9c663a9..5b0bed51f4 100644
--- a/paimon-python/pypaimon/read/reader/format_row_reader.py
+++ b/paimon-python/pypaimon/read/reader/format_row_reader.py
@@ -17,7 +17,7 @@
 
 import struct
 from decimal import Decimal
-from typing import Any, List, Optional
+from typing import Any, List, Optional, Tuple
 
 import pyarrow as pa
 import pyarrow.dataset as ds
@@ -29,12 +29,23 @@ from pypaimon.read.reader.iface.record_batch_reader import 
RecordBatchReader
 from pypaimon.schema.data_types import (
     ArrayType, DataField, MapType, MultisetType, PyarrowFieldParser, RowType, 
VectorType, AtomicType
 )
+from pypaimon.table.special_fields import SpecialFields
 
 FOOTER_SIZE = 32
 MAGIC = 0x524F5753  # "ROWS"
 VERSION = 1
 
 
+def _special_field(name: str):
+    if name == SpecialFields.ROW_ID.name:
+        return SpecialFields.ROW_ID
+    if name == SpecialFields.SEQUENCE_NUMBER.name:
+        return SpecialFields.SEQUENCE_NUMBER
+    if name == SpecialFields.VALUE_KIND.name:
+        return SpecialFields.VALUE_KIND
+    return None
+
+
 class FormatRowReader(RecordBatchReader):
 
     def __init__(self, file_io: FileIO, file_path: str, read_fields: List[str],
@@ -48,9 +59,13 @@ class FormatRowReader(RecordBatchReader):
         self._file_size = file_io.get_file_size(file_path)
 
         full_fields_map = {field.name: field for field in full_fields}
-        self._projected_fields = [full_fields_map[name] for name in 
read_fields]
+        self._read_field_names = list(read_fields)
+        self._projected_fields = [full_fields_map[name] for name in read_fields
+                                  if name in full_fields_map]
+        self._missing_fields = [name for name in read_fields
+                                if name not in full_fields_map]
         self._all_fields = full_fields
-        self._schema = 
PyarrowFieldParser.from_paimon_schema(self._projected_fields)
+        self._physical_schema = 
PyarrowFieldParser.from_paimon_schema(self._projected_fields)
 
         self._block_compressed_sizes: List[int] = []
         self._block_uncompressed_sizes: List[int] = []
@@ -97,13 +112,12 @@ class FormatRowReader(RecordBatchReader):
         block_data = self._read_and_decompress_block(self._current_block_idx)
         self._current_block_idx += 1
 
-        columns = self._decode_block(block_data)
+        columns, row_count = self._decode_block(block_data)
 
-        if not columns or all(len(col) == 0 for col in columns):
+        if row_count == 0:
             return None
 
-        pydict = {field.name: columns[i] for i, field in 
enumerate(self._projected_fields)}
-        table = pa.Table.from_pydict(pydict, self._schema)
+        table = self._build_table(columns, row_count)
 
         if self._push_down_predicate is not None:
             dataset = ds.InMemoryDataset(table)
@@ -123,13 +137,12 @@ class FormatRowReader(RecordBatchReader):
         self._blocks_to_read_pos += 1
 
         block_data = self._read_and_decompress_block(block_idx)
-        columns = self._decode_block(block_data, row_filter=local_rows)
+        columns, row_count = self._decode_block(block_data, 
row_filter=local_rows)
 
-        if not columns or all(len(col) == 0 for col in columns):
+        if row_count == 0:
             return self._read_batch_indexed()
 
-        pydict = {field.name: columns[i] for i, field in 
enumerate(self._projected_fields)}
-        table = pa.Table.from_pydict(pydict, self._schema)
+        table = self._build_table(columns, row_count)
 
         if self._push_down_predicate is not None:
             dataset = ds.InMemoryDataset(table)
@@ -211,8 +224,35 @@ class FormatRowReader(RecordBatchReader):
         uncompressed_size = self._block_uncompressed_sizes[block_idx]
         return decompressor.decompress(compressed_data, 
max_output_size=uncompressed_size)
 
+    def _build_table(self, columns: List[List], row_count: int) -> pa.Table:
+        pydict = {
+            field.name: columns[i]
+            for i, field in enumerate(self._projected_fields)
+        }
+        if not self._missing_fields:
+            return pa.Table.from_pydict(pydict, self._physical_schema)
+
+        arrays = []
+        fields = []
+        projected_by_name = {field.name: field for field in 
self._projected_fields}
+        for name in self._read_field_names:
+            if name in projected_by_name:
+                field = projected_by_name[name]
+                pa_type = PyarrowFieldParser.from_paimon_type(field.type)
+                arrays.append(pa.array(pydict[name], type=pa_type))
+                fields.append(pa.field(name, pa_type, 
nullable=field.type.nullable))
+            else:
+                special_field = _special_field(name)
+                pa_type = (
+                    PyarrowFieldParser.from_paimon_type(special_field.type)
+                    if special_field is not None else pa.null()
+                )
+                arrays.append(pa.nulls(row_count, type=pa_type))
+                fields.append(pa.field(name, pa_type, nullable=not 
SpecialFields.is_system_field(name)))
+        return pa.Table.from_arrays(arrays, schema=pa.schema(fields))
+
     def _decode_block(self, block_data: bytes,
-                      row_filter: Optional[List[int]] = None) -> List[List]:
+                      row_filter: Optional[List[int]] = None) -> 
Tuple[List[List], int]:
         data_len = len(block_data)
         row_count = struct.unpack_from('<i', block_data, data_len - 4)[0]
         offset_array_start = data_len - 4 - row_count * 4
@@ -229,6 +269,7 @@ class FormatRowReader(RecordBatchReader):
         header_size = (arity + 7) // 8
 
         rows_to_read = row_filter if row_filter is not None else 
range(row_count)
+        rows_to_read_count = len(rows_to_read)
         proj_set = set(projected_indices)
         proj_col_map = {field_idx: col_idx for col_idx, field_idx in 
enumerate(projected_indices)}
 
@@ -249,7 +290,7 @@ class FormatRowReader(RecordBatchReader):
                     if field_idx in proj_set:
                         columns[proj_col_map[field_idx]].append(value)
 
-        return columns
+        return columns, rows_to_read_count
 
 
 class _RowDecoder:
diff --git a/paimon-python/pypaimon/read/split_read.py 
b/paimon-python/pypaimon/read/split_read.py
index d6a74353f3..231b525bbf 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -72,6 +72,7 @@ from pypaimon.globalindex.indexed_split import IndexedSplit
 KEY_PREFIX = "_KEY_"
 KEY_FIELD_ID_START = 1000000
 NULL_FIELD_INDEX = -1
+ROW_SIDECAR_FORMAT = CoreOptions.FILE_FORMAT_ROW
 
 _COMPRESS_EXTENSIONS = frozenset(['gz', 'bz2', 'deflate', 'snappy', 'lz4', 
'zst'])
 
@@ -204,12 +205,25 @@ class SplitRead(ABC):
 
         batch_size = self.table.options.read_batch_size()
 
-        # Convert global row_ranges (IndexedSplit) to local row_indices for 
native pushdown.
-        row_indices = None
+        effective_row_ranges = None
         if row_ranges is not None:
             effective_row_ranges = Range.and_(row_ranges, 
[file.row_id_range()])
             if len(effective_row_ranges) == 0:
                 return EmptyRecordBatchReader()
+
+        row_sidecar_file = self._row_sidecar_file_name(file)
+        if row_sidecar_file is not None and self._should_read_row_sidecar(
+                file,
+                effective_row_ranges,
+                row_sidecar_file,
+                
self.table.options.data_evolution_row_sidecar_max_selected_rows(),
+                
self.table.options.data_evolution_row_sidecar_max_selection_ratio()):
+            file_path = self._aligned_extra_file_path(file, row_sidecar_file)
+            file_format = ROW_SIDECAR_FORMAT
+
+        # Convert global row_ranges (IndexedSplit) to local row_indices for 
native pushdown.
+        row_indices = None
+        if effective_row_ranges is not None:
             row_index_formats = (CoreOptions.FILE_FORMAT_BLOB,
                                  CoreOptions.FILE_FORMAT_VORTEX,
                                  CoreOptions.FILE_FORMAT_LANCE,
@@ -392,6 +406,54 @@ class SplitRead(ABC):
 
         return reader
 
+    @staticmethod
+    def _row_sidecar_file_name(file: DataFileMeta) -> Optional[str]:
+        row_files = [
+            extra_file for extra_file in file.extra_files
+            if SplitRead._is_row_sidecar_file(extra_file)
+        ]
+        return row_files[0] if len(row_files) == 1 else None
+
+    @staticmethod
+    def _should_read_row_sidecar(file: DataFileMeta,
+                                 effective_row_ranges: Optional[List[Range]],
+                                 row_sidecar_file: Optional[str],
+                                 max_selected_rows: int,
+                                 max_selection_ratio: float) -> bool:
+        if (not effective_row_ranges
+                or file.row_count <= 0
+                or DataFileMeta.is_blob_file(file.file_name)
+                or DataFileMeta.is_vector_file(file.file_name)
+                or row_sidecar_file is None):
+            return False
+
+        selected_row_count = sum(
+            r.count()
+            for r in Range.sort_and_merge_overlap(effective_row_ranges, True)
+        )
+        if selected_row_count <= 0 or selected_row_count >= file.row_count:
+            return False
+
+        selection_ratio = float(selected_row_count) / float(file.row_count)
+        return (selected_row_count <= max_selected_rows
+                and selection_ratio <= max_selection_ratio)
+
+    @staticmethod
+    def _is_row_sidecar_file(file_name: str) -> bool:
+        try:
+            return format_identifier(file_name) == ROW_SIDECAR_FORMAT
+        except Exception:
+            return False
+
+    @staticmethod
+    def _aligned_extra_file_path(file: DataFileMeta, extra_file: str) -> str:
+        if "://" in extra_file or extra_file.startswith("/"):
+            return extra_file
+        file_path = file.external_path if file.external_path else 
file.file_path
+        if not file_path or "/" not in file_path:
+            return extra_file
+        return f"{file_path.rsplit('/', 1)[0]}/{extra_file}"
+
     def _get_fields_and_predicate(self, schema_id: int, read_fields):
         key = (schema_id, tuple(read_fields))
         if key not in self.schema_id_2_fields:
diff --git a/paimon-python/pypaimon/tests/data_evolution_formats_test.py 
b/paimon-python/pypaimon/tests/data_evolution_formats_test.py
index b30560c400..50801ac7a6 100644
--- a/paimon-python/pypaimon/tests/data_evolution_formats_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_formats_test.py
@@ -31,6 +31,10 @@ import pyarrow as pa
 
 from pypaimon import CatalogFactory, Schema
 from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.manifest.schema.simple_stats import SimpleStats
+from pypaimon.read.split_read import SplitRead
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.utils.range import Range
 
 
 class DataEvolutionFormatsTest(unittest.TestCase):
@@ -50,10 +54,190 @@ class DataEvolutionFormatsTest(unittest.TestCase):
     def _file_path(file_meta):
         return file_meta.external_path if file_meta.external_path else 
file_meta.file_path
 
+    @staticmethod
+    def _extra_file_path(file_meta, extra_file):
+        if "://" in extra_file or extra_file.startswith("/"):
+            return extra_file
+        file_path = DataEvolutionFormatsTest._file_path(file_meta)
+        return f"{file_path.rsplit('/', 1)[0]}/{extra_file}"
+
+    @staticmethod
+    def _row_sidecar_files(file_meta):
+        return [extra_file for extra_file in file_meta.extra_files
+                if extra_file.endswith('.row')]
+
+    @staticmethod
+    def _data_file_meta(file_name, first_row_id, row_count, extra_files):
+        return DataFileMeta.create(
+            file_name=file_name,
+            file_size=1,
+            row_count=row_count,
+            min_key=GenericRow([], []),
+            max_key=GenericRow([], []),
+            key_stats=SimpleStats.empty_stats(),
+            value_stats=SimpleStats.empty_stats(),
+            min_sequence_number=0,
+            max_sequence_number=0,
+            schema_id=0,
+            level=0,
+            extra_files=extra_files,
+            first_row_id=first_row_id,
+        )
+
     # ------------------------------------------------------------------
     # Parquet-format data evolution
     # ------------------------------------------------------------------
 
+    def test_row_sidecar_selection_requires_small_count_and_low_ratio(self):
+        small_file = self._data_file_meta('file1.parquet', 10, 100, 
['file1.row'])
+        large_file = self._data_file_meta('file2.parquet', 10, 1000000, 
['file2.row'])
+
+        self.assertTrue(SplitRead._should_read_row_sidecar(
+            large_file, [Range(10, 4105)], 'file2.row', 4096, 0.05))
+        self.assertFalse(SplitRead._should_read_row_sidecar(
+            small_file, [Range(10, 15)], 'file1.row', 4096, 0.05))
+        self.assertFalse(SplitRead._should_read_row_sidecar(
+            large_file, [Range(10, 4106)], 'file2.row', 4096, 0.05))
+        self.assertTrue(SplitRead._should_read_row_sidecar(
+            small_file, [Range(10, 15)], 'file1.row', 64, 0.25))
+
+    def test_row_sidecar_disabled_by_default(self):
+        pa_schema = pa.schema([
+            ('id', pa.int32()),
+            ('val', pa.string()),
+        ])
+        schema = Schema.from_pyarrow_schema(pa_schema, options={
+            'row-tracking.enabled': 'true',
+            'data-evolution.enabled': 'true',
+            'file.format': 'parquet',
+        })
+        self.catalog.create_table('default.fmt_row_sidecar_default_off', 
schema, False)
+        table = self.catalog.get_table('default.fmt_row_sidecar_default_off')
+
+        wb = table.new_batch_write_builder()
+        tw = wb.new_write()
+        tc = wb.new_commit()
+        tw.write_arrow(pa.Table.from_pydict(
+            {'id': [1, 2], 'val': ['a', 'b']}, schema=pa_schema))
+        cmts = tw.prepare_commit()
+        tc.commit(cmts)
+        tw.close()
+        tc.close()
+
+        data_files = [nf for m in cmts for nf in m.new_files
+                      if nf.file_name.endswith('.parquet')]
+        self.assertGreater(len(data_files), 0)
+        for file_meta in data_files:
+            self.assertEqual([], self._row_sidecar_files(file_meta))
+
+    def test_row_sidecar_enabled_writes_extra_file(self):
+        pa_schema = pa.schema([
+            ('id', pa.int32()),
+            ('val', pa.string()),
+        ])
+        schema = Schema.from_pyarrow_schema(pa_schema, options={
+            'row-tracking.enabled': 'true',
+            'data-evolution.enabled': 'true',
+            'data-evolution.row-sidecar.enabled': 'true',
+            'file.format': 'parquet',
+        })
+        self.catalog.create_table('default.fmt_row_sidecar_enabled', schema, 
False)
+        table = self.catalog.get_table('default.fmt_row_sidecar_enabled')
+
+        wb = table.new_batch_write_builder()
+        tw = wb.new_write()
+        tc = wb.new_commit()
+        tw.write_arrow(pa.Table.from_pydict(
+            {'id': [1, 2, 3], 'val': ['a', 'b', 'c']}, schema=pa_schema))
+        cmts = tw.prepare_commit()
+        tc.commit(cmts)
+        tw.close()
+        tc.close()
+
+        data_files = [nf for m in cmts for nf in m.new_files
+                      if nf.file_name.endswith('.parquet')]
+        self.assertGreater(len(data_files), 0)
+        for file_meta in data_files:
+            sidecars = self._row_sidecar_files(file_meta)
+            self.assertEqual(1, len(sidecars))
+            self.assertTrue(os.path.exists(self._extra_file_path(file_meta, 
sidecars[0])))
+
+    def test_row_sidecar_not_written_by_dedicated_format_writer(self):
+        pa_schema = pa.schema([
+            ('id', pa.int32()),
+            ('payload', pa.large_binary()),
+        ])
+        schema = Schema.from_pyarrow_schema(pa_schema, options={
+            'row-tracking.enabled': 'true',
+            'data-evolution.enabled': 'true',
+            'data-evolution.row-sidecar.enabled': 'true',
+            'file.format': 'parquet',
+        })
+        self.catalog.create_table('default.fmt_row_sidecar_dedicated_writer', 
schema, False)
+        table = 
self.catalog.get_table('default.fmt_row_sidecar_dedicated_writer')
+
+        wb = table.new_batch_write_builder()
+        tw = wb.new_write()
+        tc = wb.new_commit()
+        tw.write_arrow(pa.Table.from_pydict({
+            'id': [1, 2],
+            'payload': pa.array([b'a', b'b'], type=pa.large_binary()),
+        }, schema=pa_schema))
+        cmts = tw.prepare_commit()
+        tc.commit(cmts)
+        tw.close()
+        tc.close()
+
+        all_files = [nf for m in cmts for nf in m.new_files]
+        self.assertGreater(len([f for f in all_files if 
f.file_name.endswith('.parquet')]), 0)
+        self.assertGreater(len([f for f in all_files if 
f.file_name.endswith('.blob')]), 0)
+        for file_meta in all_files:
+            self.assertEqual([], self._row_sidecar_files(file_meta))
+
+    def test_row_sidecar_serves_sparse_row_id_read(self):
+        pa_schema = pa.schema([
+            ('id', pa.int32()),
+            ('val', pa.string()),
+        ])
+        schema = Schema.from_pyarrow_schema(pa_schema, options={
+            'row-tracking.enabled': 'true',
+            'data-evolution.enabled': 'true',
+            'data-evolution.row-sidecar.enabled': 'true',
+            'file.format': 'parquet',
+        })
+        self.catalog.create_table('default.fmt_row_sidecar_sparse_read', 
schema, False)
+        table = self.catalog.get_table('default.fmt_row_sidecar_sparse_read')
+
+        wb = table.new_batch_write_builder()
+        tw = wb.new_write()
+        tc = wb.new_commit()
+        tw.write_arrow(pa.Table.from_pydict({
+            'id': list(range(100)),
+            'val': [f'v{i}' for i in range(100)],
+        }, schema=pa_schema))
+        cmts = tw.prepare_commit()
+        tc.commit(cmts)
+        tw.close()
+        tc.close()
+
+        data_files = [nf for m in cmts for nf in m.new_files
+                      if nf.file_name.endswith('.parquet')]
+        self.assertEqual(1, len(data_files))
+        sidecars = self._row_sidecar_files(data_files[0])
+        self.assertEqual(1, len(sidecars))
+        self.assertTrue(os.path.exists(self._extra_file_path(data_files[0], 
sidecars[0])))
+
+        os.remove(self._file_path(data_files[0]))
+
+        rb = table.new_read_builder().with_projection(['id', 'val', '_ROW_ID'])
+        pb = rb.new_predicate_builder()
+        rb.with_filter(pb.equal('_ROW_ID', 5))
+        actual = rb.new_read().to_arrow(rb.new_scan().plan().splits())
+        self.assertEqual(actual.num_rows, 1)
+        self.assertEqual(actual.column('id').to_pylist(), [5])
+        self.assertEqual(actual.column('val').to_pylist(), ['v5'])
+        self.assertEqual(actual.column('_ROW_ID').to_pylist(), [5])
+
     def test_parquet_column_subset_write_and_merge_read(self):
         """Write disjoint column subsets as parquet, merge-read via data 
evolution."""
         pa_schema = pa.schema([
diff --git a/paimon-python/pypaimon/write/writer/data_writer.py 
b/paimon-python/pypaimon/write/writer/data_writer.py
index 75a6359a09..c32bb6968b 100644
--- a/paimon-python/pypaimon/write/writer/data_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_writer.py
@@ -34,6 +34,8 @@ from pypaimon.table.row.generic_row import GenericRow
 class DataWriter(ABC):
     """Base class for data writers that handle PyArrow tables directly."""
 
+    ROW_SIDECAR_SUFFIX = ".row"
+
     def __init__(self, table, partition: Tuple, bucket: int, max_seq_number: 
int, options: CoreOptions = None,
                  write_cols: Optional[List[str]] = None,
                  changelog_producer: ChangelogProducer = 
ChangelogProducer.NONE):
@@ -152,6 +154,8 @@ class DataWriter(ABC):
                 if path_to_delete:
                     path_str = str(path_to_delete)
                     self.file_io.delete_quietly(path_str)
+                for extra_file in file_meta.extra_files:
+                    
self.file_io.delete_quietly(self._aligned_extra_file_path(file_meta, 
extra_file))
             except Exception as e:
                 import logging
                 logger = logging.getLogger(__name__)
@@ -198,27 +202,46 @@ class DataWriter(ABC):
         is_external_path = self.external_path_provider is not None
         external_path_str = file_path if is_external_path else None
 
+        logical_data = data
+        extra_files = []
+        row_sidecar_path = None
         if self._variant_shredding:
             data = self._apply_variant_shredding(data)
 
-        if self.file_format == CoreOptions.FILE_FORMAT_PARQUET:
-            self.file_io.write_parquet(file_path, data, 
compression=self.compression, zstd_level=self.zstd_level)
-        elif self.file_format == CoreOptions.FILE_FORMAT_ORC:
-            self.file_io.write_orc(file_path, data, 
compression=self.compression, zstd_level=self.zstd_level)
-        elif self.file_format == CoreOptions.FILE_FORMAT_AVRO:
-            self.file_io.write_avro(file_path, data, 
compression=self.compression, zstd_level=self.zstd_level)
-        elif self.file_format == CoreOptions.FILE_FORMAT_BLOB:
-            self.file_io.write_blob(file_path, data)
-        elif self.file_format == CoreOptions.FILE_FORMAT_LANCE:
-            self.file_io.write_lance(file_path, data)
-        elif self.file_format == CoreOptions.FILE_FORMAT_VORTEX:
-            self.file_io.write_vortex(file_path, data)
-        elif self.file_format == CoreOptions.FILE_FORMAT_MOSAIC:
-            self.file_io.write_mosaic(file_path, data)
-        elif self.file_format == CoreOptions.FILE_FORMAT_ROW:
-            self.file_io.write_row(file_path, data, zstd_level=self.zstd_level)
-        else:
-            raise ValueError(f"Unsupported file format: {self.file_format}")
+        try:
+            if self.file_format == CoreOptions.FILE_FORMAT_PARQUET:
+                self.file_io.write_parquet(file_path, data, 
compression=self.compression, zstd_level=self.zstd_level)
+            elif self.file_format == CoreOptions.FILE_FORMAT_ORC:
+                self.file_io.write_orc(file_path, data, 
compression=self.compression, zstd_level=self.zstd_level)
+            elif self.file_format == CoreOptions.FILE_FORMAT_AVRO:
+                self.file_io.write_avro(file_path, data, 
compression=self.compression, zstd_level=self.zstd_level)
+            elif self.file_format == CoreOptions.FILE_FORMAT_BLOB:
+                self.file_io.write_blob(file_path, data)
+            elif self.file_format == CoreOptions.FILE_FORMAT_LANCE:
+                self.file_io.write_lance(file_path, data)
+            elif self.file_format == CoreOptions.FILE_FORMAT_VORTEX:
+                self.file_io.write_vortex(file_path, data)
+            elif self.file_format == CoreOptions.FILE_FORMAT_MOSAIC:
+                self.file_io.write_mosaic(file_path, data)
+            elif self.file_format == CoreOptions.FILE_FORMAT_ROW:
+                self.file_io.write_row(file_path, data, 
zstd_level=self.zstd_level)
+            else:
+                raise ValueError(f"Unsupported file format: 
{self.file_format}")
+
+            if self._should_write_row_sidecar():
+                row_sidecar_name = f"{file_name}{self.ROW_SIDECAR_SUFFIX}"
+                row_sidecar_path = f"{file_path}{self.ROW_SIDECAR_SUFFIX}"
+                self.file_io.write_row(
+                    row_sidecar_path,
+                    logical_data,
+                    fields=self._row_sidecar_fields(logical_data),
+                    zstd_level=self.zstd_level)
+                extra_files.append(row_sidecar_name)
+        except Exception:
+            self.file_io.delete_quietly(file_path)
+            if row_sidecar_path is not None:
+                self.file_io.delete_quietly(row_sidecar_path)
+            raise
 
         # min key & max key
 
@@ -264,7 +287,7 @@ class DataWriter(ABC):
             max_sequence_number=max_seq,
             schema_id=self.table.table_schema.id,
             level=0,
-            extra_files=[],
+            extra_files=extra_files,
             creation_time=creation_time,
             delete_row_count=0,
             file_source=0,
@@ -359,6 +382,27 @@ class DataWriter(ABC):
         bucket_path = self.path_factory.bucket_path(self.partition, 
self.bucket)
         return f"{bucket_path.rstrip('/')}/{file_name}"
 
+    def _should_write_row_sidecar(self) -> bool:
+        return (
+            self.options.data_evolution_enabled(False)
+            and self.options.data_evolution_row_sidecar_enabled(False)
+        )
+
+    def _row_sidecar_fields(self, data: pa.Table) -> List:
+        if self.write_cols:
+            field_map = {field.name: field for field in 
self.table.table_schema.fields}
+            return [field_map[name] for name in self.write_cols if name in 
field_map]
+        return PyarrowFieldParser.to_paimon_schema(data.schema)
+
+    @staticmethod
+    def _aligned_extra_file_path(file_meta: DataFileMeta, extra_file: str) -> 
str:
+        if "://" in extra_file or extra_file.startswith("/"):
+            return extra_file
+        file_path = file_meta.external_path if file_meta.external_path else 
file_meta.file_path
+        if not file_path or "/" not in file_path:
+            return extra_file
+        return f"{file_path.rsplit('/', 1)[0]}/{extra_file}"
+
     @staticmethod
     def _find_optimal_split_point(data: pa.RecordBatch, target_size: int) -> 
int:
         total_rows = data.num_rows


Reply via email to