You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@paimon.apache.org by lz...@apache.org on 2026/10/15 03:57:07 UTC

(paimon) branch master updated: [file-index] Support stream file index containers (#10101)

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 22ea78d2b6 [file-index] Support stream file index containers (#10101)
22ea78d2b6 is described below

commit 22ea78d2b6457f939233f351b2d48c55e2b237bd
Author: Zhang Jiawei <30...@users.noreply.github.com>
AuthorDate: Thu Oct 15 11:57:00 2026 +0800

    [file-index] Support stream file index containers (#10101)
---
 docs/docs/concepts/spec/fileindex.mdx              |  69 +++-
 docs/generated/core_configuration.html             |   8 +-
 .../main/java/org/apache/paimon/CoreOptions.java   |  27 +-
 .../apache/paimon/fileindex/FileIndexOptions.java  |   6 +
 .../apache/paimon/fileindex/FileIndexFormat.java   | 382 +++++++++------------
 .../paimon/fileindex/FileIndexFormatUtils.java     | 148 ++++++++
 .../apache/paimon/fileindex/FileIndexFormatV1.java | 189 ++++++++++
 .../apache/paimon/fileindex/FileIndexFormatV2.java | 158 +++++++++
 .../paimon/fileindex/FileIndexPredicate.java       |  43 ++-
 .../apache/paimon/fileindex/FileIndexWriter.java   |  13 +-
 .../org/apache/paimon/fileindex/FileIndexer.java   |  15 +
 .../paimon/fileindex/bitmap/BitmapFileIndex.java   |  10 +-
 .../bloomfilter/BloomFilterFileIndex.java          |  16 +-
 .../bsi/BitSliceIndexBitmapFileIndex.java          |   5 +
 .../fileindex/rangebitmap/BitSliceIndexBitmap.java |   4 +-
 .../paimon/fileindex/rangebitmap/RangeBitmap.java  |   6 +-
 .../rangebitmap/RangeBitmapFileIndex.java          |   7 +-
 .../dictionary/chunked/ChunkedDictionary.java      |   4 +-
 .../dictionary/chunked/FixedLengthChunk.java       |   4 +-
 .../rangebitmap/dictionary/chunked/KeyFactory.java |   6 +-
 .../dictionary/chunked/VariableLengthChunk.java    |   4 +-
 .../FileIndexFormatCompatibilityTest.java          | 309 +++++++++++++++++
 .../fileindex/FileIndexFormatFormatTest.java       | 322 +++++++++++++++--
 .../fileindex/FileIndexPredicateCloseTest.java     |  68 +++-
 .../paimon/fileindex/FileIndexPredicateTest.java   |  17 +-
 .../fileindex/FileIndexerCompatibilityTest.java    | 187 ++++++++++
 .../apache/paimon/index/FileIndexProcessor.java    | 182 +++++-----
 .../org/apache/paimon/io/DataFileIndexWriter.java  | 114 +++---
 .../paimon/io/SpillableIndexOutputStream.java      | 113 ++++++
 .../paimon/table/system/FileIndexesTable.java      |   6 +-
 .../java/org/apache/paimon/CoreOptionsTest.java    |  18 +
 ...DedicatedFormatRollingFileWriterVectorTest.java |   4 +-
 .../paimon/index/FileIndexProcessorTest.java       |  79 ++++-
 .../apache/paimon/io/DataFileIndexWriterTest.java  | 138 +++++++-
 .../apache/paimon/io/FileIndexEvaluatorTest.java   |  13 +-
 .../org/apache/paimon/io/SparseFileIndexIO.java    |  84 +++++
 .../paimon/io/StreamingTestFileIndexFactory.java   |  90 +++++
 .../paimon/table/system/FileIndexesTableTest.java  |  30 +-
 .../org.apache.paimon.fileindex.FileIndexerFactory |  16 +
 .../flink/action/RewriteFileIndexActionITCase.java |   4 +-
 .../procedure/RewriteFileIndexProcedureITCase.java |  20 +-
 .../apache/paimon/spark/SparkFileIndexITCase.java  |   8 +-
 42 files changed, 2473 insertions(+), 473 deletions(-)

diff --git a/docs/docs/concepts/spec/fileindex.mdx b/docs/docs/concepts/spec/fileindex.mdx
index e782df6f47..6fde4d722b 100644
--- a/docs/docs/concepts/spec/fileindex.mdx
+++ b/docs/docs/concepts/spec/fileindex.mdx
@@ -37,7 +37,7 @@ deletion vectors, see [Table Index](./tableindex).
 
 | Encoding | Section |
 | --- | --- |
-| Shared header and column offsets | [Index File](#index-file) |
+| File index container | [Index File](#index-file) |
 | Bloom filter | [BloomFilter](#index-bloomfilter) |
 | Bitmap | [Bitmap](#index-bitmap) |
 | Range bitmap | [Range Bitmap](#index-range-bitmap) |
@@ -45,7 +45,13 @@ deletion vectors, see [Table Index](./tableindex).
 
 ## Index File
 
-File index file format. Put all column and offset in the header.
+The container version is selected by `file-index.format.version` (default: `1`). Readers support
+both versions. Enable version `2` after upgrading the readers of the table. The container
+version is independent of each index type's payload version.
+
+### V1
+
+V1 puts the index entries in the header before the payloads.
 
 <pre>
  ______________________________________    _____________________
@@ -95,6 +101,65 @@ redundant bytes:                  var bytes (for compatibility with later versio
 BODY:                             column index bytes + column index bytes + column index bytes + .......
 </pre>
 
+### V2
+
+V2 writes the payloads first, followed by the footer containing the index entries and a fixed
+12-byte trailer.
+All integers are signed and big-endian; strings use Java `writeUTF` encoding.
+
+<pre>
+ ______________________________________    _____________________
+|     magic    |        version        |          PREFIX
+|--------------------------------------|    ---------------------
+|                BODY                  |
+|                BODY                  |           BODY
+|                BODY                  |
+|--------------------------------------|    ---------------------
+|            column number             |
+|--------------------------------------|
+|   column 1        |  index number    |
+|--------------------------------------|
+|  index name 1 | start pos | length   |
+|--------------------------------------|
+|  index name 2 | start pos | length   |
+|--------------------------------------|
+|   column 2        |  index number    |
+|--------------------------------------|
+|  index name 1 | start pos | length   |
+|--------------------------------------|
+|  index name 2 | start pos | length   |
+|--------------------------------------|
+|                 ...                  |          FOOTER
+|--------------------------------------|
+|                 ...                  |
+|--------------------------------------|    ---------------------
+| footer length |      tail magic      |          TRAILER
+|______________________________________|    _____________________
+
+magic:                            8 bytes long, value is 1493475289347502L, BIG_ENDIAN
+version:                          4 bytes int, BIG_ENDIAN
+BODY:                             column index bytes + column index bytes + .......
+column number:                    4 bytes int, BIG_ENDIAN
+column x name:                    var bytes, Java modified-utf-8
+index number:                     4 bytes int (how many column items below), BIG_ENDIAN
+index name x:                     var bytes, Java modified-utf-8
+start pos:                        8 bytes long, BIG_ENDIAN
+length:                           8 bytes long, BIG_ENDIAN
+footer length:                    4 bytes int, BIG_ENDIAN
+tail magic:                       8 bytes long, value is PAFIDX02
+</pre>
+
+`start pos` is relative to the container start. For a container of length `F`, the footer starts at
+`F - 12 - footer length`.
+`FileIndexFormat.createReader` receives the container length so it can locate the trailer and
+footer.
+
+V2 supports a container larger than 2 GiB by storing payload positions and lengths as 64-bit
+values. Payloads are written through `FileIndexWriter.writeTo(OutputStream)`; existing byte-array
+writers use its default implementation. `FileIndexer.createReader` receives both the start position
+and length as 64-bit values. Index implementations that load a payload into a `byte[]`, such as
+Bloom filter, may reject lengths greater than `Integer.MAX_VALUE`.
+
 ## Index: BloomFilter 
 
 Options are:
diff --git a/docs/generated/core_configuration.html b/docs/generated/core_configuration.html
index 4c30f37c5a..d0ad00368d 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -716,11 +716,17 @@ under the License.
             <td>String</td>
             <td>Default aggregate function of all fields for partial-update and aggregate merge function.</td>
         </tr>
+        <tr>
+            <td><h5>file-index.format.version</h5></td>
+            <td style="word-wrap: break-word;">1</td>
+            <td>Integer</td>
+            <td>File index container version to write (1 or 2). Version 2 supports 64-bit payload positions and lengths.</td>
+        </tr>
         <tr>
             <td><h5>file-index.in-manifest-threshold</h5></td>
             <td style="word-wrap: break-word;">500 bytes</td>
             <td>MemorySize</td>
-            <td>The threshold to store file index bytes in manifest.</td>
+            <td>The threshold to store file index bytes in manifest. Valid range: 0 to 2,147,483,647 bytes.</td>
         </tr>
         <tr>
             <td><h5>file-index.read.enabled</h5></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 f9cb0914f6..b350e3b2b2 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -433,7 +433,15 @@ public class CoreOptions implements Serializable {
             key("file-index.in-manifest-threshold")
                     .memoryType()
                     .defaultValue(MemorySize.parse("500 B"))
-                    .withDescription("The threshold to store file index bytes in manifest.");
+                    .withDescription(
+                            "The threshold to store file index bytes in manifest. Valid range: 0 to 2,147,483,647 bytes.");
+
+    public static final ConfigOption<Integer> FILE_INDEX_FORMAT_VERSION =
+            key("file-index.format.version")
+                    .intType()
+                    .defaultValue(1)
+                    .withDescription(
+                            "File index container version to write (1 or 2). Version 2 supports 64-bit payload positions and lengths.");
 
     public static final ConfigOption<Boolean> FILE_INDEX_READ_ENABLED =
             key("file-index.read.enabled")
@@ -4564,8 +4572,23 @@ public class CoreOptions implements Serializable {
         return new FileIndexOptions(this);
     }
 
+    public int fileIndexFormatVersion() {
+        int version = options.get(FILE_INDEX_FORMAT_VERSION);
+        if (version != 1 && version != 2) {
+            throw new IllegalArgumentException("file-index.format.version must be 1 or 2");
+        }
+        return version;
+    }
+
     public long fileIndexInManifestThreshold() {
-        return options.get(FILE_INDEX_IN_MANIFEST_THRESHOLD).getBytes();
+        long bytes = options.get(FILE_INDEX_IN_MANIFEST_THRESHOLD).getBytes();
+        checkArgument(
+                bytes >= 0 && bytes <= Integer.MAX_VALUE,
+                "'%s' must be between 0 and %s bytes, but was %s bytes.",
+                FILE_INDEX_IN_MANIFEST_THRESHOLD.key(),
+                Integer.MAX_VALUE,
+                bytes);
+        return bytes;
     }
 
     public boolean fileIndexReadEnabled() {
diff --git a/paimon-api/src/main/java/org/apache/paimon/fileindex/FileIndexOptions.java b/paimon-api/src/main/java/org/apache/paimon/fileindex/FileIndexOptions.java
index 4dd34dbadc..3dbfc8c621 100644
--- a/paimon-api/src/main/java/org/apache/paimon/fileindex/FileIndexOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/fileindex/FileIndexOptions.java
@@ -40,6 +40,7 @@ public class FileIndexOptions {
 
     // if the filter size greater than fileIndexInManifestThreshold, we put it in file
     private final long fileIndexInManifestThreshold;
+    private final int formatVersion;
 
     private final Map<Column, Map<String, Options>> indexTypeOptions;
     private final Map<Column, Map<String, Options>> topLevelMapColumnOptions;
@@ -52,6 +53,7 @@ public class FileIndexOptions {
         this.indexTypeOptions = new HashMap<>();
         this.topLevelMapColumnOptions = new HashMap<>();
         this.fileIndexInManifestThreshold = coreOptions.fileIndexInManifestThreshold();
+        this.formatVersion = coreOptions.fileIndexFormatVersion();
         setupOptions(coreOptions);
     }
 
@@ -186,6 +188,10 @@ public class FileIndexOptions {
         return indexTypeOptions.isEmpty();
     }
 
+    public int formatVersion() {
+        return formatVersion;
+    }
+
     public long fileIndexInManifestThreshold() {
         return fileIndexInManifestThreshold;
     }
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormat.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormat.java
index c2d27ec9f0..0359d6073f 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormat.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormat.java
@@ -18,7 +18,6 @@
 
 package org.apache.paimon.fileindex;
 
-import org.apache.paimon.annotation.VisibleForTesting;
 import org.apache.paimon.fileindex.empty.EmptyFileIndexReader;
 import org.apache.paimon.fs.SeekableInputStream;
 import org.apache.paimon.options.Options;
@@ -27,84 +26,38 @@ import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.IOUtils;
 import org.apache.paimon.utils.Pair;
 
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
+import javax.annotation.Nullable;
+
 import java.io.Closeable;
 import java.io.DataInputStream;
-import java.io.DataOutput;
-import java.io.DataOutputStream;
 import java.io.EOFException;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
 
-/**
- * File index file format. Put all column and offset in the header.
- *
- * <pre>
- *  ______________________________________    _____________________
- * |     magic    |version|head length  |
- * |--------------------------------------|
- * |            column number             |
- * |--------------------------------------|
- * |   column 1        | index number    |
- * |--------------------------------------|
- * |  index name 1 |start pos |length   |
- * |--------------------------------------|
- * |  index name 2 |start pos |length   |
- * |--------------------------------------|
- * |  index name 3 |start pos |length   |
- * |--------------------------------------|            HEAD
- * |   column 2        | index number    |
- * |--------------------------------------|
- * |  index name 1 |start pos |length   |
- * |--------------------------------------|
- * |  index name 2 |start pos |length   |
- * |--------------------------------------|
- * |  index name 3 |start pos |length   |
- * |--------------------------------------|
- * |                 ...                  |
- * |--------------------------------------|
- * |                 ...                  |
- * |--------------------------------------|
- * |  redundant length |redundant bytes  |
- * |--------------------------------------|    ---------------------
- * |                BODY                  |
- * |                BODY                  |
- * |                BODY                  |             BODY
- * |                BODY                  |
- * |______________________________________|    _____________________
- *
- * magic:                            8 bytes long
- * version:                          4 bytes int
- * head length:                      4 bytes int
- * column number:                    4 bytes int
- * column x:                         var bytes utf (length + bytes)
- * index number:                     4 bytes int (how many column items below)
- * index name x:                     var bytes utf
- * start pos:                        4 bytes int
- * length:                           4 bytes int
- * redundant length:                 4 bytes int (for compatibility with later versions, in this version, content is zero)
- * redundant bytes:                  var bytes (for compatibility with later version, in this version, is empty)
- * BODY:                             column index bytes + column index bytes + column index bytes + .......
- *
- * </pre>
- */
+import static org.apache.paimon.fileindex.FileIndexFormatUtils.EMPTY_INDEX_FLAG;
+import static org.apache.paimon.fileindex.FileIndexFormatUtils.VERSION_1;
+import static org.apache.paimon.fileindex.FileIndexFormatUtils.VERSION_2;
+
+/** Version-dispatching entry point and shared payload access for file index containers. */
 public final class FileIndexFormat {
 
-    private static final long MAGIC = 1493475289347502L;
-    private static final int EMPTY_INDEX_FLAG = -1;
+    /** Writes a single payload to the container output. */
+    @FunctionalInterface
+    public interface Payload {
+        void writeTo(OutputStream output) throws IOException;
+    }
 
     enum Version {
-        V_1(1);
+        V_1(VERSION_1),
+        V_2(VERSION_2);
 
         private final int version;
 
@@ -117,15 +70,45 @@ public final class FileIndexFormat {
         }
     }
 
+    public static Writer createWriter(OutputStream outputStream, int version) throws IOException {
+        if (version == Version.V_1.version()) {
+            return new FileIndexFormatV1.Writer(outputStream);
+        } else if (version == Version.V_2.version()) {
+            return new FileIndexFormatV2.Writer(outputStream);
+        } else {
+            throw new IllegalArgumentException("Unsupported file index version: " + version);
+        }
+    }
+
+    /** @deprecated Use {@link #createWriter(OutputStream, int)}. */
+    @Deprecated
     public static Writer createWriter(OutputStream outputStream) {
-        return new Writer(outputStream);
+        return new FileIndexFormatV1.Writer(outputStream);
+    }
+
+    public static Reader createReader(
+            SeekableInputStream inputStream, RowType fileRowType, long length) {
+        return new Reader(inputStream, fileRowType, length);
+    }
+
+    static Reader createReaderAfterVersion(
+            SeekableInputStream inputStream, RowType fileRowType, long length, int version) {
+        return new Reader(inputStream, fileRowType, length, version);
     }
 
+    /** @deprecated Use {@link #createReader(SeekableInputStream, RowType, long)}. */
+    @Deprecated
     public static Reader createReader(SeekableInputStream inputStream, RowType fileRowType) {
         return new Reader(inputStream, fileRowType);
     }
 
-    /** Creates a reader for accessing header metadata without reading index payloads. */
+    /** Creates a reader for accessing index metadata without reading index payloads. */
+    public static Reader createMetadataReader(SeekableInputStream inputStream, long length) {
+        return createReader(inputStream, RowType.builder().build(), length);
+    }
+
+    /** @deprecated Use {@link #createMetadataReader(SeekableInputStream, long)}. */
+    @Deprecated
     public static Reader createMetadataReader(SeekableInputStream inputStream) {
         return new Reader(inputStream, RowType.builder().build());
     }
@@ -135,10 +118,11 @@ public final class FileIndexFormat {
 
         private final String columnName;
         private final String indexType;
-        private final int sizeInBytes;
+        private final long sizeInBytes;
         private final boolean empty;
 
-        private FileIndexMeta(String columnName, String indexType, int sizeInBytes, boolean empty) {
+        private FileIndexMeta(
+                String columnName, String indexType, long sizeInBytes, boolean empty) {
             this.columnName = columnName;
             this.indexType = indexType;
             this.sizeInBytes = sizeInBytes;
@@ -153,7 +137,13 @@ public final class FileIndexFormat {
             return indexType;
         }
 
+        /** @deprecated Use {@link #sizeInBytesLong()}. */
+        @Deprecated
         public int sizeInBytes() {
+            return Math.toIntExact(sizeInBytes);
+        }
+
+        public long sizeInBytesLong() {
             return sizeInBytes;
         }
 
@@ -165,109 +155,51 @@ public final class FileIndexFormat {
     /** Writer for file index file. */
     public static class Writer implements Closeable {
 
-        private final DataOutputStream dataOutputStream;
+        @Nullable private final Writer delegate;
 
-        // for version compatible
-        private static final int REDUNDANT_LENGTH = 0;
+        protected Writer() {
+            this.delegate = null;
+        }
 
+        /** @deprecated Use {@link FileIndexFormat#createWriter(OutputStream, int)}. */
+        @Deprecated
         public Writer(OutputStream outputStream) {
-            this.dataOutputStream = new DataOutputStream(outputStream);
+            this.delegate = new FileIndexFormatV1.Writer(outputStream);
         }
 
+        /**
+         * @deprecated Use {@link #writeIndex(String, String, Payload)} and {@link #finish()} to
+         *     stream payloads into the container.
+         */
+        @Deprecated
         public void writeColumnIndexes(Map<String, Map<String, byte[]>> indexes)
                 throws IOException {
-
-            Map<String, Map<String, Pair<Integer, Integer>>> bodyInfo = new LinkedHashMap<>();
-
-            // construct body
-            ByteArrayOutputStream baos = new ByteArrayOutputStream(256);
-            for (Map.Entry<String, Map<String, byte[]>> columnMap : indexes.entrySet()) {
-                Map<String, Pair<Integer, Integer>> innerMap =
-                        bodyInfo.computeIfAbsent(columnMap.getKey(), k -> new LinkedHashMap<>());
-                Map<String, byte[]> bytesMap = columnMap.getValue();
-                for (Map.Entry<String, byte[]> entry : bytesMap.entrySet()) {
-                    int startPosition = baos.size();
-                    byte[] v = entry.getValue();
-                    if (v == null) {
-                        innerMap.put(entry.getKey(), Pair.of(EMPTY_INDEX_FLAG, 0));
-                    } else {
-                        baos.write(entry.getValue());
-                        innerMap.put(
-                                entry.getKey(),
-                                Pair.of(startPosition, baos.size() - startPosition));
-                    }
+            for (Map.Entry<String, Map<String, byte[]>> column : indexes.entrySet()) {
+                for (Map.Entry<String, byte[]> index : column.getValue().entrySet()) {
+                    byte[] payload = index.getValue();
+                    writeIndex(
+                            column.getKey(),
+                            index.getKey(),
+                            payload == null ? null : output -> output.write(payload));
                 }
             }
-            byte[] body = baos.toByteArray();
-            writeHead(bodyInfo);
-
-            // writeBody
-            dataOutputStream.write(body);
+            finish();
         }
 
-        private void writeHead(Map<String, Map<String, Pair<Integer, Integer>>> bodyInfo)
+        /** Writes one payload to the container. A null payload is empty. */
+        public void writeIndex(String columnName, String indexType, @Nullable Payload payload)
                 throws IOException {
-
-            int headLength = calculateHeadLength(bodyInfo);
-
-            // writeMagic
-            dataOutputStream.writeLong(MAGIC);
-            // writeVersion
-            dataOutputStream.writeInt(Version.V_1.version());
-            // writeHeadLength
-            dataOutputStream.writeInt(headLength);
-            // writeColumnSize
-            dataOutputStream.writeInt(bodyInfo.size());
-            for (Map.Entry<String, Map<String, Pair<Integer, Integer>>> entry :
-                    bodyInfo.entrySet()) {
-                // writeColumnName
-                dataOutputStream.writeUTF(entry.getKey());
-                // writeIndexTypeSize
-                dataOutputStream.writeInt(entry.getValue().size());
-                // writeColumnInfo, offset = headLength
-                for (Map.Entry<String, Pair<Integer, Integer>> indexEntry :
-                        entry.getValue().entrySet()) {
-                    dataOutputStream.writeUTF(indexEntry.getKey());
-                    int start = indexEntry.getValue().getLeft();
-                    dataOutputStream.writeInt(
-                            start == EMPTY_INDEX_FLAG ? EMPTY_INDEX_FLAG : start + headLength);
-                    dataOutputStream.writeInt(indexEntry.getValue().getRight());
-                }
-            }
-            // writeRedundantLength
-            dataOutputStream.writeInt(REDUNDANT_LENGTH);
+            delegate.writeIndex(columnName, indexType, payload);
         }
 
-        private int calculateHeadLength(Map<String, Map<String, Pair<Integer, Integer>>> bodyInfo)
-                throws IOException {
-            // magic 8 bytes, version 4 bytes, head length 4 bytes,
-            // column number 4 bytes, body info start&length 8 bytes per
-            // column-index, index number size 4 bytes per column, redundant length 4 bytes;
-            int baseLength =
-                    8
-                            + 4
-                            + 4
-                            + 4
-                            + bodyInfo.values().stream().mapToInt(Map::size).sum() * 8
-                            + bodyInfo.size() * 4
-                            + 4;
-
-            ByteArrayOutputStream baos = new ByteArrayOutputStream();
-            DataOutput dataOutput = new DataOutputStream(baos);
-            for (Map.Entry<String, Map<String, Pair<Integer, Integer>>> entry :
-                    bodyInfo.entrySet()) {
-                dataOutput.writeUTF(entry.getKey());
-                for (String s : entry.getValue().keySet()) {
-                    dataOutput.writeUTF(s);
-                }
-            }
-
-            return baseLength + baos.size();
+        /** Completes the container after all payloads have been written. */
+        public void finish() throws IOException {
+            delegate.finish();
         }
 
         @Override
         public void close() throws IOException {
-            IOUtils.closeQuietly(dataOutputStream);
+            delegate.close();
         }
     }
 
@@ -275,49 +207,54 @@ public final class FileIndexFormat {
     public static class Reader implements Closeable {
 
         private final SeekableInputStream seekableInputStream;
-        // get header and cache it.
-        private final Map<String, Map<String, Pair<Integer, Integer>>> header = new HashMap<>();
+        // Cache the index entries.
+        private final Map<String, Map<String, Pair<Long, Long>>> indexEntries = new HashMap<>();
         private final Map<String, DataField> fields = new HashMap<>();
 
+        /**
+         * @deprecated Use {@link FileIndexFormat#createReader(SeekableInputStream, RowType, long)}.
+         */
+        @Deprecated
         public Reader(SeekableInputStream seekableInputStream, RowType fileRowType) {
+            this(seekableInputStream, fileRowType, -1L);
+        }
+
+        private Reader(SeekableInputStream seekableInputStream, RowType fileRowType, long length) {
+            this(seekableInputStream, fileRowType, length, null);
+        }
+
+        private Reader(
+                SeekableInputStream seekableInputStream,
+                RowType fileRowType,
+                long length,
+                @Nullable Integer version) {
             this.seekableInputStream = seekableInputStream;
             DataInputStream dataInputStream = new DataInputStream(seekableInputStream);
             fileRowType.getFields().forEach(field -> this.fields.put(field.name(), field));
             try {
-                long magic = dataInputStream.readLong();
-                if (magic != MAGIC) {
-                    throw new RuntimeException("This file is not file index file.");
-                }
-
-                int version = dataInputStream.readInt();
-                if (version != Version.V_1.version()) {
+                int actualVersion =
+                        version == null
+                                ? FileIndexFormatUtils.readVersion(dataInputStream)
+                                : version;
+                if (actualVersion == Version.V_2.version()) {
+                    if (length < 0) {
+                        throw new RuntimeException(
+                                "File index length is required to read version " + actualVersion);
+                    }
+                    indexEntries.putAll(
+                            FileIndexFormatV2.readIndexEntries(seekableInputStream, length));
+                } else if (actualVersion == Version.V_1.version()) {
+                    indexEntries.putAll(FileIndexFormatV1.readIndexEntries(dataInputStream));
+                } else {
                     throw new RuntimeException(
                             "This index file is version of "
-                                    + version
+                                    + actualVersion
                                     + ", not in supported version list ["
                                     + Version.V_1.version()
+                                    + ", "
+                                    + Version.V_2.version()
                                     + "]");
                 }
-
-                int headLength = dataInputStream.readInt();
-                byte[] head = new byte[headLength - 8 - 4 - 4];
-                dataInputStream.readFully(head);
-
-                try (DataInputStream dataInput =
-                        new DataInputStream(new ByteArrayInputStream(head))) {
-                    int columnSize = dataInput.readInt();
-                    for (int i = 0; i < columnSize; i++) {
-                        String columnName = dataInput.readUTF();
-                        int indexSize = dataInput.readInt();
-                        Map<String, Pair<Integer, Integer>> indexMap =
-                                this.header.computeIfAbsent(columnName, n -> new HashMap<>());
-                        for (int j = 0; j < indexSize; j++) {
-                            indexMap.put(
-                                    dataInput.readUTF(),
-                                    Pair.of(dataInput.readInt(), dataInput.readInt()));
-                        }
-                    }
-                }
             } catch (IOException | RuntimeException e) {
                 // Callers wrap the constructor in try-with-resources on the stream,
                 // but a throwing constructor never assigns the resource, so both
@@ -329,7 +266,7 @@ public final class FileIndexFormat {
         }
 
         public Set<FileIndexReader> readColumnIndex(String columnName) {
-            return Optional.ofNullable(header.getOrDefault(columnName, null))
+            return Optional.ofNullable(indexEntries.getOrDefault(columnName, null))
                     .map(
                             f ->
                                     f.entrySet().stream()
@@ -343,14 +280,14 @@ public final class FileIndexFormat {
                     .orElse(Collections.emptySet());
         }
 
-        /** Returns the index metadata parsed from the header without reading index payloads. */
+        /** Returns the parsed index metadata without reading index payloads. */
         public List<FileIndexMeta> indexMetas() {
             List<FileIndexMeta> metas = new ArrayList<>();
-            for (Map.Entry<String, Map<String, Pair<Integer, Integer>>> columnEntry :
-                    header.entrySet()) {
-                for (Map.Entry<String, Pair<Integer, Integer>> indexEntry :
+            for (Map.Entry<String, Map<String, Pair<Long, Long>>> columnEntry :
+                    indexEntries.entrySet()) {
+                for (Map.Entry<String, Pair<Long, Long>> indexEntry :
                         columnEntry.getValue().entrySet()) {
-                    Pair<Integer, Integer> startAndLength = indexEntry.getValue();
+                    Pair<Long, Long> startAndLength = indexEntry.getValue();
                     metas.add(
                             new FileIndexMeta(
                                     columnEntry.getKey(),
@@ -363,7 +300,7 @@ public final class FileIndexFormat {
         }
 
         private FileIndexReader getFileIndexReader(
-                String columnName, String indexType, Pair<Integer, Integer> startAndLength) {
+                String columnName, String indexType, Pair<Long, Long> startAndLength) {
             if (startAndLength.getLeft() == EMPTY_INDEX_FLAG) {
                 return EmptyFileIndexReader.INSTANCE;
             }
@@ -377,47 +314,58 @@ public final class FileIndexFormat {
                             startAndLength.getRight());
         }
 
-        private byte[] getBytesWithStartAndLength(Pair<Integer, Integer> startAndLength) {
-            byte[] b = new byte[startAndLength.getRight()];
-            try {
-                seekableInputStream.seek(startAndLength.getLeft());
-                int n = 0;
-                int len = b.length;
-                // read fully until b is full else throw.
-                while (n < len) {
-                    int count = seekableInputStream.read(b, n, len - n);
-                    if (count < 0) {
-                        throw new EOFException();
-                    }
-                    n += count;
+        /**
+         * Copies a stored payload without loading it into a byte array or decoding its plugin
+         * format.
+         */
+        public void copyPayload(String columnName, String indexType, OutputStream output)
+                throws IOException {
+            Pair<Long, Long> startAndLength = indexEntries.get(columnName).get(indexType);
+            seekableInputStream.seek(startAndLength.getLeft());
+            byte[] buffer = new byte[8192];
+            long remaining = startAndLength.getRight();
+            while (remaining > 0) {
+                int count =
+                        seekableInputStream.read(
+                                buffer, 0, (int) Math.min(remaining, buffer.length));
+                if (count < 0) {
+                    throw new EOFException();
                 }
-            } catch (IOException e) {
-                throw new RuntimeException(e);
+                output.write(buffer, 0, count);
+                remaining -= count;
             }
-            return b;
         }
 
+        /** @deprecated Read payloads through {@link #copyPayload(String, String, OutputStream)}. */
+        @Deprecated
         public Map<String, Map<String, byte[]>> readAll() {
             Map<String, Map<String, byte[]>> result = new HashMap<>();
-            for (Map.Entry<String, Map<String, Pair<Integer, Integer>>> entryOuter :
-                    header.entrySet()) {
-                for (Map.Entry<String, Pair<Integer, Integer>> entryInner :
-                        entryOuter.getValue().entrySet()) {
-                    result.computeIfAbsent(entryOuter.getKey(), key -> new HashMap<>())
-                            .put(
-                                    entryInner.getKey(),
-                                    getBytesWithStartAndLength(entryInner.getValue()));
+            for (Map.Entry<String, Map<String, Pair<Long, Long>>> column :
+                    indexEntries.entrySet()) {
+                for (Map.Entry<String, Pair<Long, Long>> index : column.getValue().entrySet()) {
+                    result.computeIfAbsent(column.getKey(), key -> new HashMap<>())
+                            .put(index.getKey(), readPayload(index.getValue()));
                 }
             }
             return result;
         }
 
-        @VisibleForTesting
-        // only for test yet
-        Optional<byte[]> getBytesWithNameAndType(String columnName, String indexType) {
-            return Optional.ofNullable(header.getOrDefault(columnName, null))
-                    .map(i -> i.getOrDefault(indexType, null))
-                    .map(this::getBytesWithStartAndLength);
+        private byte[] readPayload(Pair<Long, Long> startAndLength) {
+            byte[] payload = new byte[Math.toIntExact(startAndLength.getRight())];
+            try {
+                seekableInputStream.seek(startAndLength.getLeft());
+                int read = 0;
+                while (read < payload.length) {
+                    int count = seekableInputStream.read(payload, read, payload.length - read);
+                    if (count < 0) {
+                        throw new EOFException();
+                    }
+                    read += count;
+                }
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+            return payload;
         }
 
         @Override
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormatUtils.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormatUtils.java
new file mode 100644
index 0000000000..79c427b296
--- /dev/null
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormatUtils.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.fileindex;
+
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.utils.Pair;
+
+import java.io.DataInput;
+import java.io.DataOutput;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Utilities shared by file index container versions. */
+final class FileIndexFormatUtils {
+
+    static final long MAGIC = 1493475289347502L;
+    static final int EMPTY_INDEX_FLAG = -1;
+    static final int VERSION_1 = 1;
+    static final int VERSION_2 = 2;
+
+    private FileIndexFormatUtils() {}
+
+    static void writeMagicAndVersion(DataOutput output, int version) throws IOException {
+        // writeMagic
+        output.writeLong(MAGIC);
+        // writeVersion
+        output.writeInt(version);
+    }
+
+    static int readVersion(DataInput input) throws IOException {
+        if (input.readLong() != MAGIC) {
+            throw new RuntimeException("This file is not file index file.");
+        }
+        return input.readInt();
+    }
+
+    static Map<String, Map<String, Pair<Long, Long>>> readIndexEntries(DataInput input, int version)
+            throws IOException {
+        Map<String, Map<String, Pair<Long, Long>>> indexEntries = new HashMap<>();
+        int columnSize = input.readInt();
+        for (int i = 0; i < columnSize; i++) {
+            String columnName = input.readUTF();
+            int indexSize = input.readInt();
+            Map<String, Pair<Long, Long>> entries =
+                    indexEntries.computeIfAbsent(columnName, ignored -> new HashMap<>());
+            for (int j = 0; j < indexSize; j++) {
+                String indexType = input.readUTF();
+                Pair<Long, Long> startAndLength =
+                        version == VERSION_1
+                                ? Pair.of((long) input.readInt(), (long) input.readInt())
+                                : Pair.of(input.readLong(), input.readLong());
+                entries.put(indexType, startAndLength);
+            }
+        }
+        return indexEntries;
+    }
+
+    static void writeIndexEntries(
+            DataOutput output,
+            Map<String, Map<String, Pair<Long, Long>>> indexEntries,
+            int version,
+            long startOffset)
+            throws IOException {
+        // writeColumnSize
+        output.writeInt(indexEntries.size());
+        for (Map.Entry<String, Map<String, Pair<Long, Long>>> column : indexEntries.entrySet()) {
+            // writeColumnName
+            output.writeUTF(column.getKey());
+            // writeIndexTypeSize
+            output.writeInt(column.getValue().size());
+            for (Map.Entry<String, Pair<Long, Long>> index : column.getValue().entrySet()) {
+                output.writeUTF(index.getKey());
+                long start = index.getValue().getLeft();
+                // Keep EMPTY_INDEX_FLAG unchanged when converting a relative start position.
+                start = start == EMPTY_INDEX_FLAG ? EMPTY_INDEX_FLAG : start + startOffset;
+                if (version == VERSION_1) {
+                    output.writeInt((int) start);
+                    output.writeInt(index.getValue().getRight().intValue());
+                } else {
+                    output.writeLong(start);
+                    output.writeLong(index.getValue().getRight());
+                }
+            }
+        }
+    }
+
+    static class CountingPositionOutputStream extends PositionOutputStream {
+
+        private final OutputStream output;
+        private long position;
+
+        CountingPositionOutputStream(OutputStream output) {
+            this.output = output;
+        }
+
+        @Override
+        public long getPos() {
+            return position;
+        }
+
+        @Override
+        public void write(int value) throws IOException {
+            long next = Math.addExact(position, 1L);
+            output.write(value);
+            position = next;
+        }
+
+        @Override
+        public void write(byte[] bytes) throws IOException {
+            write(bytes, 0, bytes.length);
+        }
+
+        @Override
+        public void write(byte[] bytes, int offset, int length) throws IOException {
+            long next = Math.addExact(position, length);
+            output.write(bytes, offset, length);
+            position = next;
+        }
+
+        @Override
+        public void flush() throws IOException {
+            output.flush();
+        }
+
+        @Override
+        public void close() throws IOException {
+            output.close();
+        }
+    }
+}
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormatV1.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormatV1.java
new file mode 100644
index 0000000000..f8a288b36c
--- /dev/null
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormatV1.java
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.fileindex;
+
+import org.apache.paimon.utils.Pair;
+
+import javax.annotation.Nullable;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutput;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.apache.paimon.fileindex.FileIndexFormatUtils.VERSION_1;
+
+/**
+ * V1 file index container: header followed by payloads.
+ *
+ * <pre>
+ *  ______________________________________    _____________________
+ * |     magic    | version| head length  |
+ * |--------------------------------------|
+ * |            column number             |
+ * |--------------------------------------|
+ * |   column 1        |  index number    |
+ * |--------------------------------------|
+ * |  index name 1 | start pos | length   |
+ * |--------------------------------------|
+ * |  index name 2 | start pos | length   |
+ * |--------------------------------------|
+ * |  index name 3 | start pos | length   |
+ * |--------------------------------------|            HEAD
+ * |   column 2        |  index number    |
+ * |--------------------------------------|
+ * |  index name 1 | start pos | length   |
+ * |--------------------------------------|
+ * |  index name 2 | start pos | length   |
+ * |--------------------------------------|
+ * |  index name 3 | start pos | length   |
+ * |--------------------------------------|
+ * |                 ...                  |
+ * |--------------------------------------|
+ * |                 ...                  |
+ * |--------------------------------------|
+ * |  redundant length | redundant bytes  |
+ * |--------------------------------------|    ---------------------
+ * |                BODY                  |
+ * |                BODY                  |
+ * |                BODY                  |             BODY
+ * |                BODY                  |
+ * |______________________________________|    _____________________
+ *
+ * magic:                            8 bytes long
+ * version:                          4 bytes int
+ * head length:                      4 bytes int
+ * column number:                    4 bytes int
+ * column x:                         var bytes utf (length + bytes)
+ * index number:                     4 bytes int (how many column items below)
+ * index name x:                     var bytes utf
+ * start pos:                        4 bytes int
+ * length:                           4 bytes int
+ * redundant length:                 4 bytes int (for compatibility with later versions, in this version, content is zero)
+ * redundant bytes:                  var bytes (for compatibility with later version, in this version, is empty)
+ * BODY:                             column index bytes + column index bytes + column index bytes + .......
+ *
+ * </pre>
+ */
+final class FileIndexFormatV1 {
+
+    private FileIndexFormatV1() {}
+
+    /** Reads the header after the common magic and version have been consumed. */
+    static Map<String, Map<String, Pair<Long, Long>>> readIndexEntries(
+            DataInputStream dataInputStream) throws IOException {
+        int headLength = dataInputStream.readInt();
+        byte[] head = new byte[headLength - 8 - 4 - 4];
+        dataInputStream.readFully(head);
+        try (DataInputStream dataInput = new DataInputStream(new ByteArrayInputStream(head))) {
+            return FileIndexFormatUtils.readIndexEntries(dataInput, VERSION_1);
+        }
+    }
+
+    /** V1 writer buffers payloads to compute the preceding header. */
+    static final class Writer extends FileIndexFormat.Writer {
+
+        private final DataOutputStream dataOutputStream;
+        private final ByteArrayOutputStream body = new ByteArrayOutputStream(256);
+        private final Map<String, Map<String, Pair<Long, Long>>> indexEntries =
+                new LinkedHashMap<>();
+
+        // for version compatible
+        private static final int REDUNDANT_LENGTH = 0;
+
+        Writer(OutputStream outputStream) {
+            this.dataOutputStream = new DataOutputStream(outputStream);
+        }
+
+        @Override
+        public void writeIndex(
+                String columnName, String indexType, @Nullable FileIndexFormat.Payload payload)
+                throws IOException {
+            Map<String, Pair<Long, Long>> column =
+                    indexEntries.computeIfAbsent(columnName, ignored -> new LinkedHashMap<>());
+            if (payload == null) {
+                // Empty indexes have no payload and use EMPTY_INDEX_FLAG as their start position.
+                column.put(indexType, Pair.of((long) FileIndexFormatUtils.EMPTY_INDEX_FLAG, 0L));
+            } else {
+                int start = body.size();
+                payload.writeTo(body);
+                column.put(indexType, Pair.of((long) start, (long) body.size() - start));
+            }
+        }
+
+        @Override
+        public void finish() throws IOException {
+            writeHead(indexEntries);
+
+            // writeBody
+            body.writeTo(dataOutputStream);
+        }
+
+        private void writeHead(Map<String, Map<String, Pair<Long, Long>>> indexEntries)
+                throws IOException {
+
+            int headLength = calculateHeadLength(indexEntries);
+
+            FileIndexFormatUtils.writeMagicAndVersion(dataOutputStream, VERSION_1);
+            // writeHeadLength
+            dataOutputStream.writeInt(headLength);
+            // writeColumnInfo, offset = headLength
+            FileIndexFormatUtils.writeIndexEntries(
+                    dataOutputStream, indexEntries, VERSION_1, headLength);
+            // writeRedundantLength
+            dataOutputStream.writeInt(REDUNDANT_LENGTH);
+        }
+
+        private int calculateHeadLength(Map<String, Map<String, Pair<Long, Long>>> indexEntries)
+                throws IOException {
+            // magic 8 bytes, version 4 bytes, head length 4 bytes,
+            // column number 4 bytes, body info start&length 8 bytes per
+            // column-index, index number size 4 bytes per column, redundant length 4 bytes;
+            int baseLength =
+                    8
+                            + 4
+                            + 4
+                            + 4
+                            + indexEntries.values().stream().mapToInt(Map::size).sum() * 8
+                            + indexEntries.size() * 4
+                            + 4;
+
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            DataOutput dataOutput = new DataOutputStream(baos);
+            for (Map.Entry<String, Map<String, Pair<Long, Long>>> entry : indexEntries.entrySet()) {
+                dataOutput.writeUTF(entry.getKey());
+                for (String s : entry.getValue().keySet()) {
+                    dataOutput.writeUTF(s);
+                }
+            }
+
+            return baseLength + baos.size();
+        }
+
+        @Override
+        public void close() throws IOException {
+            dataOutputStream.close();
+        }
+    }
+}
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormatV2.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormatV2.java
new file mode 100644
index 0000000000..1dfb6505be
--- /dev/null
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexFormatV2.java
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.fileindex;
+
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.utils.Pair;
+
+import javax.annotation.Nullable;
+
+import java.io.ByteArrayInputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.apache.paimon.fileindex.FileIndexFormatUtils.VERSION_2;
+
+/**
+ * V2 file index container: prefix and body followed by footer and trailer.
+ *
+ * <pre>
+ *  ______________________________________    _____________________
+ * |     magic    |        version        |          PREFIX
+ * |--------------------------------------|    ---------------------
+ * |                BODY                  |
+ * |                BODY                  |           BODY
+ * |                BODY                  |
+ * |--------------------------------------|    ---------------------
+ * |            column number             |
+ * |--------------------------------------|
+ * |   column 1        |  index number    |
+ * |--------------------------------------|
+ * |  index name 1 | start pos | length   |
+ * |--------------------------------------|
+ * |  index name 2 | start pos | length   |
+ * |--------------------------------------|          FOOTER
+ * |   column 2        |  index number    |
+ * |--------------------------------------|
+ * |  index name 1 | start pos | length   |
+ * |--------------------------------------|
+ * |  index name 2 | start pos | length   |
+ * |--------------------------------------|
+ * |                 ...                  |
+ * |--------------------------------------|
+ * |                 ...                  |
+ * |--------------------------------------|    ---------------------
+ * | footer length |      tail magic      |          TRAILER
+ * |______________________________________|    _____________________
+ *
+ * magic:                            8 bytes long
+ * version:                          4 bytes int
+ * BODY:                             column index bytes + column index bytes + .......
+ * column number:                    4 bytes int
+ * column x:                         var bytes utf (length + bytes)
+ * index number:                     4 bytes int (how many column items below)
+ * index name x:                     var bytes utf
+ * start pos:                        8 bytes long
+ * length:                           8 bytes long
+ * footer length:                    4 bytes int
+ * tail magic:                       8 bytes long
+ *
+ * </pre>
+ */
+final class FileIndexFormatV2 {
+
+    // ASCII PAFIDX02.
+    private static final long TAIL_MAGIC = 0x5041464944583032L;
+    private static final int TRAILER_LENGTH = 12;
+
+    private FileIndexFormatV2() {}
+
+    static Map<String, Map<String, Pair<Long, Long>>> readIndexEntries(
+            SeekableInputStream seekableInputStream, long length) throws IOException {
+        seekableInputStream.seek(length - TRAILER_LENGTH);
+        DataInputStream tail = new DataInputStream(seekableInputStream);
+        int footerLength = tail.readInt();
+        if (tail.readLong() != TAIL_MAGIC) {
+            throw new IOException("Invalid file index tail magic");
+        }
+        long footerStart = length - TRAILER_LENGTH - footerLength;
+        byte[] footer = new byte[footerLength];
+        seekableInputStream.seek(footerStart);
+        new DataInputStream(seekableInputStream).readFully(footer);
+        try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(footer))) {
+            return FileIndexFormatUtils.readIndexEntries(in, VERSION_2);
+        }
+    }
+
+    /** V2 writer records streamed payload ranges in the footer. */
+    static final class Writer extends FileIndexFormat.Writer {
+
+        private final DataOutputStream dataOutputStream;
+        private final FileIndexFormatUtils.CountingPositionOutputStream positionOutputStream;
+        private final Map<String, Map<String, Pair<Long, Long>>> indexEntries =
+                new LinkedHashMap<>();
+
+        Writer(OutputStream outputStream) throws IOException {
+            this.positionOutputStream =
+                    new FileIndexFormatUtils.CountingPositionOutputStream(outputStream);
+            this.dataOutputStream = new DataOutputStream(positionOutputStream);
+            FileIndexFormatUtils.writeMagicAndVersion(dataOutputStream, VERSION_2);
+        }
+
+        @Override
+        public void writeIndex(
+                String columnName, String indexType, @Nullable FileIndexFormat.Payload payload)
+                throws IOException {
+            Map<String, Pair<Long, Long>> column =
+                    indexEntries.computeIfAbsent(columnName, ignored -> new LinkedHashMap<>());
+            if (payload == null) {
+                // Empty indexes have no payload and use EMPTY_INDEX_FLAG as their start position.
+                column.put(indexType, Pair.of((long) FileIndexFormatUtils.EMPTY_INDEX_FLAG, 0L));
+            } else {
+                long start = positionOutputStream.getPos();
+                payload.writeTo(dataOutputStream);
+                column.put(indexType, Pair.of(start, positionOutputStream.getPos() - start));
+            }
+        }
+
+        @Override
+        public void finish() throws IOException {
+            writeFooter(indexEntries);
+        }
+
+        private void writeFooter(Map<String, Map<String, Pair<Long, Long>>> indexEntries)
+                throws IOException {
+            long start = positionOutputStream.getPos();
+            FileIndexFormatUtils.writeIndexEntries(dataOutputStream, indexEntries, VERSION_2, 0L);
+            // writeFooterLength
+            dataOutputStream.writeInt(Math.toIntExact(positionOutputStream.getPos() - start));
+            // writeTailMagic
+            dataOutputStream.writeLong(TAIL_MAGIC);
+        }
+
+        @Override
+        public void close() throws IOException {
+            dataOutputStream.close();
+        }
+    }
+}
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexPredicate.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexPredicate.java
index 82d14cf7ca..6874f8116d 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexPredicate.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexPredicate.java
@@ -32,6 +32,7 @@ import org.apache.paimon.predicate.PredicateVisitor;
 import org.apache.paimon.predicate.SortValue;
 import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.IOUtils;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -39,6 +40,7 @@ import org.slf4j.LoggerFactory;
 import javax.annotation.Nullable;
 
 import java.io.Closeable;
+import java.io.DataInputStream;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
@@ -59,20 +61,53 @@ public class FileIndexPredicate implements Closeable {
     @Nullable private Path path;
 
     public FileIndexPredicate(Path path, FileIO fileIO, RowType fileRowType) throws IOException {
-        this(fileIO.newInputStream(path), fileRowType);
+        this.reader = createReader(path, fileIO, fileRowType);
         this.path = path;
     }
 
     public FileIndexPredicate(byte[] serializedBytes, RowType fileRowType) {
-        this(new ByteArraySeekableStream(serializedBytes), fileRowType);
+        this(new ByteArraySeekableStream(serializedBytes), fileRowType, serializedBytes.length);
     }
 
+    /**
+     * @deprecated Use {@link #FileIndexPredicate(SeekableInputStream, RowType, long)} for V2
+     *     containers.
+     */
+    @Deprecated
     public FileIndexPredicate(SeekableInputStream inputStream, RowType fileRowType) {
-        // createReader itself closes the stream when the header fails validation, so
-        // there is no stream to release here anymore.
         this.reader = FileIndexFormat.createReader(inputStream, fileRowType);
     }
 
+    public FileIndexPredicate(
+            SeekableInputStream inputStream, RowType fileRowType, long containerLength) {
+        // createReader itself closes the stream when container validation fails, so
+        // there is no stream to release here anymore.
+        this.reader = FileIndexFormat.createReader(inputStream, fileRowType, containerLength);
+    }
+
+    private static FileIndexFormat.Reader createReader(
+            Path path, FileIO fileIO, RowType fileRowType) throws IOException {
+        SeekableInputStream inputStream = fileIO.newInputStream(path);
+        int version;
+        try {
+            version = FileIndexFormatUtils.readVersion(new DataInputStream(inputStream));
+        } catch (IOException | RuntimeException e) {
+            IOUtils.closeQuietly(inputStream);
+            throw new RuntimeException("Exception happens while construct file index reader.", e);
+        }
+
+        long length = -1;
+        if (version == FileIndexFormatUtils.VERSION_2) {
+            try {
+                length = fileIO.getFileStatus(path).getLen();
+            } catch (IOException | RuntimeException e) {
+                IOUtils.closeQuietly(inputStream);
+                throw e;
+            }
+        }
+        return FileIndexFormat.createReaderAfterVersion(inputStream, fileRowType, length, version);
+    }
+
     public FileIndexResult evaluate(@Nullable Predicate predicate) {
         if (predicate == null) {
             return REMAIN;
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexWriter.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexWriter.java
index ede678353a..ad506b3799 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexWriter.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexWriter.java
@@ -18,6 +18,9 @@
 
 package org.apache.paimon.fileindex;
 
+import java.io.IOException;
+import java.io.OutputStream;
+
 /** To write file index. */
 public abstract class FileIndexWriter {
 
@@ -31,7 +34,15 @@ public abstract class FileIndexWriter {
     /** The key object may be reused, if saved in memory, please be sure to manually copy it. */
     public abstract void write(Object key);
 
-    public abstract byte[] serializedBytes();
+    public byte[] serializedBytes() {
+        throw new UnsupportedOperationException(
+                "This file index writer requires streaming output.");
+    }
+
+    /** Writes one payload. Existing byte-array writers can use the default implementation. */
+    public void writeTo(OutputStream output) throws IOException {
+        output.write(serializedBytes());
+    }
 
     public boolean empty() {
         return empty;
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexer.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexer.java
index d6c51286a5..d1549d86a6 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexer.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/FileIndexer.java
@@ -32,8 +32,23 @@ public interface FileIndexer {
 
     FileIndexWriter createWriter();
 
+    /** @deprecated Override the long overload to support positions and lengths beyond int32. */
+    @Deprecated
     FileIndexReader createReader(SeekableInputStream inputStream, int start, int length);
 
+    default FileIndexReader createReader(SeekableInputStream inputStream, long start, long length) {
+        if (start < Integer.MIN_VALUE
+                || start > Integer.MAX_VALUE
+                || length < Integer.MIN_VALUE
+                || length > Integer.MAX_VALUE) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "File index payload range exceeds int32: start = %s, length = %s.",
+                            start, length));
+        }
+        return createReader(inputStream, (int) start, (int) length);
+    }
+
     static FileIndexer create(String type, DataType dataType, Options options) {
         FileIndexerFactory fileIndexerFactory = FileIndexerFactoryUtils.load(type);
         return fileIndexerFactory.create(dataType, options);
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/bitmap/BitmapFileIndex.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/bitmap/BitmapFileIndex.java
index d74b0a5f2d..fab7b247ec 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/bitmap/BitmapFileIndex.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/bitmap/BitmapFileIndex.java
@@ -70,6 +70,12 @@ public class BitmapFileIndex implements FileIndexer {
     @Override
     public FileIndexReader createReader(
             SeekableInputStream seekableInputStream, int start, int length) {
+        return createReader(seekableInputStream, (long) start, (long) length);
+    }
+
+    @Override
+    public FileIndexReader createReader(
+            SeekableInputStream seekableInputStream, long start, long length) {
         try {
             Reader reader = new Reader(seekableInputStream, start, options);
             return valuesAreTruncated(dataType) ? new TruncatedValueReader(reader) : reader;
@@ -209,7 +215,7 @@ public class BitmapFileIndex implements FileIndexer {
     private static class Reader extends FileIndexReader {
 
         private final SeekableInputStream seekableInputStream;
-        private final int headStart;
+        private final long headStart;
         private final Map<Object, RoaringBitmap32> bitmaps = new LinkedHashMap<>();
 
         private BitmapFileIndexMeta bitmapFileIndexMeta;
@@ -217,7 +223,7 @@ public class BitmapFileIndex implements FileIndexer {
 
         private final Options options;
 
-        public Reader(SeekableInputStream seekableInputStream, int start, Options options) {
+        public Reader(SeekableInputStream seekableInputStream, long start, Options options) {
             this.seekableInputStream = seekableInputStream;
             this.headStart = start;
             this.options = options;
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/bloomfilter/BloomFilterFileIndex.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/bloomfilter/BloomFilterFileIndex.java
index 2364466dc8..458b9f6f91 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/bloomfilter/BloomFilterFileIndex.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/bloomfilter/BloomFilterFileIndex.java
@@ -71,9 +71,15 @@ public class BloomFilterFileIndex implements FileIndexer {
 
     @Override
     public FileIndexReader createReader(SeekableInputStream inputStream, int start, int length) {
+        return createReader(inputStream, (long) start, (long) length);
+    }
+
+    @Override
+    public FileIndexReader createReader(SeekableInputStream inputStream, long start, long length) {
+        int payloadLength = checkedPayloadLength(length);
         try {
             inputStream.seek(start);
-            byte[] serializedBytes = new byte[length];
+            byte[] serializedBytes = new byte[payloadLength];
             IOUtils.readFully(inputStream, serializedBytes);
             return new Reader(dataType, serializedBytes);
         } catch (IOException e) {
@@ -81,6 +87,14 @@ public class BloomFilterFileIndex implements FileIndexer {
         }
     }
 
+    private static int checkedPayloadLength(long length) {
+        if (length > Integer.MAX_VALUE) {
+            throw new IllegalArgumentException(
+                    "bloom-filter file index payload length exceeds int32: " + length);
+        }
+        return (int) length;
+    }
+
     private static class Writer extends FileIndexWriter {
 
         private final BloomFilter64 filter;
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/bsi/BitSliceIndexBitmapFileIndex.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/bsi/BitSliceIndexBitmapFileIndex.java
index e48e0934fc..91e0911d3e 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/bsi/BitSliceIndexBitmapFileIndex.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/bsi/BitSliceIndexBitmapFileIndex.java
@@ -69,6 +69,11 @@ public class BitSliceIndexBitmapFileIndex implements FileIndexer {
 
     @Override
     public FileIndexReader createReader(SeekableInputStream inputStream, int start, int length) {
+        return createReader(inputStream, (long) start, (long) length);
+    }
+
+    @Override
+    public FileIndexReader createReader(SeekableInputStream inputStream, long start, long length) {
         try {
             inputStream.seek(start);
             DataInput input = new DataInputStream(inputStream);
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/BitSliceIndexBitmap.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/BitSliceIndexBitmap.java
index deb65ce00f..27acb96df0 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/BitSliceIndexBitmap.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/BitSliceIndexBitmap.java
@@ -41,12 +41,12 @@ public class BitSliceIndexBitmap {
     private final ByteBuffer indexes;
     private final RoaringBitmap32[] slices;
     private final SeekableInputStream in;
-    private final int bodyOffset;
+    private final long bodyOffset;
 
     private RoaringBitmap32 ebm;
     private boolean initialized = false;
 
-    public BitSliceIndexBitmap(SeekableInputStream in, int offset) throws IOException {
+    public BitSliceIndexBitmap(SeekableInputStream in, long offset) throws IOException {
         in.seek(offset);
         byte[] headerLengthInBytes = new byte[Integer.BYTES];
         readFully(in, headerLengthInBytes);
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/RangeBitmap.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/RangeBitmap.java
index 784eb1fca2..6e8f86e3e0 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/RangeBitmap.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/RangeBitmap.java
@@ -49,8 +49,8 @@ public class RangeBitmap {
     @Nullable private final Object min;
     @Nullable private final Object max;
     private final int cardinality;
-    private final int dictionaryOffset;
-    private final int bsiOffset;
+    private final long dictionaryOffset;
+    private final long bsiOffset;
 
     private final SeekableInputStream in;
     private final KeyFactory factory;
@@ -59,7 +59,7 @@ public class RangeBitmap {
     private Dictionary dictionary;
     private BitSliceIndexBitmap bsi;
 
-    public RangeBitmap(SeekableInputStream in, int offset, KeyFactory factory) {
+    public RangeBitmap(SeekableInputStream in, long offset, KeyFactory factory) {
         ByteBuffer headers;
         int headerLength;
         try {
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/RangeBitmapFileIndex.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/RangeBitmapFileIndex.java
index 0cfa5fa56f..1e1b4e7ff2 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/RangeBitmapFileIndex.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/RangeBitmapFileIndex.java
@@ -61,6 +61,11 @@ public class RangeBitmapFileIndex implements FileIndexer {
 
     @Override
     public FileIndexReader createReader(SeekableInputStream in, int start, int length) {
+        return createReader(in, (long) start, (long) length);
+    }
+
+    @Override
+    public FileIndexReader createReader(SeekableInputStream in, long start, long length) {
         return new Reader(dataType, in, start);
     }
 
@@ -102,7 +107,7 @@ public class RangeBitmapFileIndex implements FileIndexer {
         private final Function<Object, Object> converter;
         private final RangeBitmap bitmap;
 
-        public Reader(DataType dataType, SeekableInputStream in, int start) {
+        public Reader(DataType dataType, SeekableInputStream in, long start) {
             KeyFactory factory = KeyFactory.create(dataType);
             this.converter = factory.createConverter();
             this.bitmap = new RangeBitmap(in, start, factory);
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/ChunkedDictionary.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/ChunkedDictionary.java
index 6a719fdad0..b7ec86fb5c 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/ChunkedDictionary.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/ChunkedDictionary.java
@@ -37,7 +37,7 @@ public class ChunkedDictionary implements Dictionary {
     private final int size;
     private final int offsetsLength;
     private final int chunksLength;
-    private final int bodyOffset;
+    private final long bodyOffset;
     private final SeekableInputStream in;
     private final KeyFactory factory;
     private final Comparator<Object> comparator;
@@ -46,7 +46,7 @@ public class ChunkedDictionary implements Dictionary {
     private ByteBuffer offsets;
     private ByteBuffer chunks;
 
-    public ChunkedDictionary(SeekableInputStream in, int offset, KeyFactory factory)
+    public ChunkedDictionary(SeekableInputStream in, long offset, KeyFactory factory)
             throws IOException {
         in.seek(offset);
         byte[] headerLengthInBytes = new byte[Integer.BYTES];
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/FixedLengthChunk.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/FixedLengthChunk.java
index 90504d1ad2..d2c3ab970e 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/FixedLengthChunk.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/FixedLengthChunk.java
@@ -38,7 +38,7 @@ public class FixedLengthChunk extends AbstractChunk {
     private final int fixedLength;
 
     private int size;
-    private int keysBaseOffset;
+    private long keysBaseOffset;
     private int keysLength;
     private SeekableInputStream in;
     private ByteBuffer keys;
@@ -65,7 +65,7 @@ public class FixedLengthChunk extends AbstractChunk {
 
     public FixedLengthChunk(
             ByteBuffer headers,
-            int keysBaseOffset,
+            long keysBaseOffset,
             SeekableInputStream in,
             KeyFactory.KeyDeserializer deserializer,
             Comparator<Object> comparator) {
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/KeyFactory.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/KeyFactory.java
index e8f4e2897e..f0e985528b 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/KeyFactory.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/KeyFactory.java
@@ -146,7 +146,7 @@ public interface KeyFactory {
 
     Chunk createChunk(Object key, int code, int offset, int limitedSerializedSizeInBytes);
 
-    Chunk mmapChunk(ByteBuffer buffer, int offset, SeekableInputStream in);
+    Chunk mmapChunk(ByteBuffer buffer, long offset, SeekableInputStream in);
 
     /** The key serializer. */
     interface KeySerializer {
@@ -176,7 +176,7 @@ public interface KeyFactory {
         }
 
         @Override
-        public Chunk mmapChunk(ByteBuffer buffer, int offset, SeekableInputStream in) {
+        public Chunk mmapChunk(ByteBuffer buffer, long offset, SeekableInputStream in) {
             return new FixedLengthChunk(
                     buffer, offset, in, createDeserializer(), createComparator());
         }
@@ -198,7 +198,7 @@ public interface KeyFactory {
         }
 
         @Override
-        public Chunk mmapChunk(ByteBuffer buffer, int offset, SeekableInputStream in) {
+        public Chunk mmapChunk(ByteBuffer buffer, long offset, SeekableInputStream in) {
             return new VariableLengthChunk(
                     buffer, offset, in, createDeserializer(), createComparator());
         }
diff --git a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/VariableLengthChunk.java b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/VariableLengthChunk.java
index ddec1e446b..1ca17a38f2 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/VariableLengthChunk.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fileindex/rangebitmap/dictionary/chunked/VariableLengthChunk.java
@@ -38,7 +38,7 @@ public class VariableLengthChunk extends AbstractChunk {
     private int size;
     private int currentOffset;
 
-    private int keysBaseOffset;
+    private long keysBaseOffset;
     private int offsetsLength;
     private int keysLength;
     private SeekableInputStream in;
@@ -69,7 +69,7 @@ public class VariableLengthChunk extends AbstractChunk {
 
     public VariableLengthChunk(
             ByteBuffer headers,
-            int keysBaseOffset,
+            long keysBaseOffset,
             SeekableInputStream in,
             KeyFactory.KeyDeserializer deserializer,
             Comparator<Object> comparator) {
diff --git a/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexFormatCompatibilityTest.java b/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexFormatCompatibilityTest.java
new file mode 100644
index 0000000000..3511768eb2
--- /dev/null
+++ b/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexFormatCompatibilityTest.java
@@ -0,0 +1,309 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.fileindex;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.fileindex.bitmap.BitmapFileIndex;
+import org.apache.paimon.fileindex.bitmap.BitmapIndexResult;
+import org.apache.paimon.fs.ByteArraySeekableStream;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.RoaringBitmap32;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import javax.tools.JavaCompiler;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.ToolProvider;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.apache.paimon.fileindex.bitmap.BitmapFileIndexFactory.BITMAP_INDEX;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Binary compatibility tests for {@link FileIndexFormat}. */
+public class FileIndexFormatCompatibilityTest {
+
+    @TempDir private Path temporaryDirectory;
+
+    @Test
+    public void testLegacyApi() throws Exception {
+        Class<?> legacyClient = compileLegacyClient();
+        Map<String, Map<String, byte[]>> indexes = new LinkedHashMap<>();
+        indexes.put("f0", Collections.singletonMap("legacy", new byte[] {42}));
+
+        byte[] factoryContainer = write(legacyClient, "writeWithFactory", indexes);
+        assertThat(read(legacyClient, "readWithFactory", factoryContainer)).isEqualTo(42);
+        assertThat(readMetadata(legacyClient, factoryContainer)).isEqualTo(1);
+
+        byte[] constructorContainer = write(legacyClient, "writeWithConstructor", indexes);
+        assertThat(read(legacyClient, "readWithConstructor", constructorContainer)).isEqualTo(42);
+
+        Method createPredicate =
+                legacyClient.getDeclaredMethod(
+                        "createPredicate", SeekableInputStream.class, RowType.class);
+        RowType rowType = RowType.builder().field("f0", DataTypes.INT()).build();
+        try (FileIndexPredicate ignored =
+                (FileIndexPredicate)
+                        createPredicate.invoke(
+                                null, new ByteArraySeekableStream(v1BitmapContainer()), rowType)) {
+            PredicateBuilder predicateBuilder = new PredicateBuilder(rowType);
+            assertThat(((BitmapIndexResult) ignored.evaluate(predicateBuilder.equal(0, 42))).get())
+                    .isEqualTo(RoaringBitmap32.bitmapOf(0));
+            assertThat(ignored.evaluate(predicateBuilder.equal(0, 43)).remain()).isFalse();
+        }
+
+        Method coreThreshold = legacyClient.getDeclaredMethod("coreThreshold", CoreOptions.class);
+        assertThat((long) coreThreshold.invoke(null, new CoreOptions(new Options())))
+                .isEqualTo(500L);
+        Method fileIndexThreshold =
+                legacyClient.getDeclaredMethod("fileIndexThreshold", FileIndexOptions.class);
+        assertThat((long) fileIndexThreshold.invoke(null, new FileIndexOptions())).isEqualTo(500L);
+    }
+
+    private byte[] write(
+            Class<?> legacyClient, String methodName, Map<String, Map<String, byte[]>> indexes)
+            throws Exception {
+        Method write = legacyClient.getDeclaredMethod(methodName, OutputStream.class, Map.class);
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        write.invoke(null, output, indexes);
+        return output.toByteArray();
+    }
+
+    private int read(Class<?> legacyClient, String methodName, byte[] container) throws Exception {
+        Method read =
+                legacyClient.getDeclaredMethod(
+                        methodName, SeekableInputStream.class, RowType.class);
+        return (int)
+                read.invoke(
+                        null, new ByteArraySeekableStream(container), RowType.builder().build());
+    }
+
+    private int readMetadata(Class<?> legacyClient, byte[] container) throws Exception {
+        Method readMetadata =
+                legacyClient.getDeclaredMethod("readMetadata", SeekableInputStream.class);
+        return (int) readMetadata.invoke(null, new ByteArraySeekableStream(container));
+    }
+
+    private byte[] v1BitmapContainer() throws IOException {
+        FileIndexWriter indexWriter =
+                new BitmapFileIndex(DataTypes.INT(), new Options()).createWriter();
+        indexWriter.writeRecord(42);
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(output, 1)) {
+            writer.writeIndex("f0", BITMAP_INDEX, indexWriter::writeTo);
+            writer.finish();
+        }
+        return output.toByteArray();
+    }
+
+    private Class<?> compileLegacyClient() throws Exception {
+        Path sourceDirectory = temporaryDirectory.resolve("source");
+        Path classesDirectory = temporaryDirectory.resolve("classes");
+        Path oldFormat =
+                sourceDirectory.resolve("org/apache/paimon/fileindex/FileIndexFormat.java");
+        Path oldPredicate =
+                sourceDirectory.resolve("org/apache/paimon/fileindex/FileIndexPredicate.java");
+        Path oldCoreOptions = sourceDirectory.resolve("org/apache/paimon/CoreOptions.java");
+        Path oldFileIndexOptions =
+                sourceDirectory.resolve("org/apache/paimon/fileindex/FileIndexOptions.java");
+        Path legacyClient =
+                sourceDirectory.resolve(
+                        "org/apache/paimon/fileindex/compatible/LegacyFileIndexFormatClient.java");
+        Files.createDirectories(oldFormat.getParent());
+        Files.createDirectories(legacyClient.getParent());
+        Files.createDirectories(classesDirectory);
+
+        Files.write(
+                oldFormat,
+                Arrays.asList(
+                        "package org.apache.paimon.fileindex;",
+                        "import java.io.Closeable;",
+                        "import java.io.IOException;",
+                        "import java.io.OutputStream;",
+                        "import java.util.List;",
+                        "import java.util.Map;",
+                        "import org.apache.paimon.fs.SeekableInputStream;",
+                        "import org.apache.paimon.types.RowType;",
+                        "public final class FileIndexFormat {",
+                        "  public static Writer createWriter(OutputStream output) { return null; }",
+                        "  public static Reader createReader(SeekableInputStream input, RowType type) { return null; }",
+                        "  public static Reader createMetadataReader(SeekableInputStream input) { return null; }",
+                        "  public static class Writer implements Closeable {",
+                        "    public Writer(OutputStream output) {}",
+                        "    public void writeColumnIndexes(Map<String, Map<String, byte[]>> indexes)",
+                        "        throws IOException {}",
+                        "    public void close() throws IOException {}",
+                        "  }",
+                        "  public static class Reader implements Closeable {",
+                        "    public Reader(SeekableInputStream input, RowType type) {}",
+                        "    public Map<String, Map<String, byte[]>> readAll() { return null; }",
+                        "    public List<FileIndexMeta> indexMetas() { return null; }",
+                        "    public void close() throws IOException {}",
+                        "  }",
+                        "  public static class FileIndexMeta {",
+                        "    public int sizeInBytes() { return 0; }",
+                        "  }",
+                        "}"),
+                StandardCharsets.UTF_8);
+        Files.write(
+                oldPredicate,
+                Arrays.asList(
+                        "package org.apache.paimon.fileindex;",
+                        "import java.io.Closeable;",
+                        "import java.io.IOException;",
+                        "import org.apache.paimon.fs.SeekableInputStream;",
+                        "import org.apache.paimon.types.RowType;",
+                        "public class FileIndexPredicate implements Closeable {",
+                        "  public FileIndexPredicate(SeekableInputStream input, RowType type) {}",
+                        "  public void close() throws IOException {}",
+                        "}"),
+                StandardCharsets.UTF_8);
+        Files.write(
+                oldCoreOptions,
+                Arrays.asList(
+                        "package org.apache.paimon;",
+                        "public class CoreOptions {",
+                        "  public long fileIndexInManifestThreshold() { return 0; }",
+                        "}"),
+                StandardCharsets.UTF_8);
+        Files.write(
+                oldFileIndexOptions,
+                Arrays.asList(
+                        "package org.apache.paimon.fileindex;",
+                        "public class FileIndexOptions {",
+                        "  public long fileIndexInManifestThreshold() { return 0; }",
+                        "}"),
+                StandardCharsets.UTF_8);
+        Files.write(
+                legacyClient,
+                Arrays.asList(
+                        "package org.apache.paimon.fileindex.compatible;",
+                        "import java.io.IOException;",
+                        "import java.io.OutputStream;",
+                        "import java.util.Map;",
+                        "import org.apache.paimon.CoreOptions;",
+                        "import org.apache.paimon.fileindex.FileIndexFormat;",
+                        "import org.apache.paimon.fileindex.FileIndexOptions;",
+                        "import org.apache.paimon.fileindex.FileIndexPredicate;",
+                        "import org.apache.paimon.fs.SeekableInputStream;",
+                        "import org.apache.paimon.types.RowType;",
+                        "public class LegacyFileIndexFormatClient {",
+                        "  public static void writeWithFactory(",
+                        "      OutputStream output, Map<String, Map<String, byte[]>> indexes)",
+                        "      throws IOException {",
+                        "    try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(output)) {",
+                        "      writer.writeColumnIndexes(indexes);",
+                        "    }",
+                        "  }",
+                        "  public static void writeWithConstructor(",
+                        "      OutputStream output, Map<String, Map<String, byte[]>> indexes)",
+                        "      throws IOException {",
+                        "    try (FileIndexFormat.Writer writer = new FileIndexFormat.Writer(output)) {",
+                        "      writer.writeColumnIndexes(indexes);",
+                        "    }",
+                        "  }",
+                        "  public static int readWithFactory(SeekableInputStream input, RowType type)",
+                        "      throws IOException {",
+                        "    try (FileIndexFormat.Reader reader = FileIndexFormat.createReader(input, type)) {",
+                        "      return read(reader);",
+                        "    }",
+                        "  }",
+                        "  public static int readWithConstructor(SeekableInputStream input, RowType type)",
+                        "      throws IOException {",
+                        "    try (FileIndexFormat.Reader reader = new FileIndexFormat.Reader(input, type)) {",
+                        "      return read(reader);",
+                        "    }",
+                        "  }",
+                        "  public static int readMetadata(SeekableInputStream input) throws IOException {",
+                        "    try (FileIndexFormat.Reader reader = FileIndexFormat.createMetadataReader(input)) {",
+                        "      return reader.indexMetas().get(0).sizeInBytes();",
+                        "    }",
+                        "  }",
+                        "  private static int read(FileIndexFormat.Reader reader) {",
+                        "    if (reader.indexMetas().get(0).sizeInBytes() != 1) {",
+                        "      throw new AssertionError();",
+                        "    }",
+                        "    return reader.readAll().get(\"f0\").get(\"legacy\")[0];",
+                        "  }",
+                        "  public static FileIndexPredicate createPredicate(",
+                        "      SeekableInputStream input, RowType type) {",
+                        "    return new FileIndexPredicate(input, type);",
+                        "  }",
+                        "  public static long coreThreshold(CoreOptions options) {",
+                        "    return options.fileIndexInManifestThreshold();",
+                        "  }",
+                        "  public static long fileIndexThreshold(FileIndexOptions options) {",
+                        "    return options.fileIndexInManifestThreshold();",
+                        "  }",
+                        "}"),
+                StandardCharsets.UTF_8);
+
+        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
+        assertThat(compiler).as("A JDK compiler is required").isNotNull();
+        try (StandardJavaFileManager fileManager =
+                compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) {
+            boolean compiled =
+                    compiler.getTask(
+                                    null,
+                                    fileManager,
+                                    null,
+                                    Arrays.asList(
+                                            "-d",
+                                            classesDirectory.toString(),
+                                            "-source",
+                                            "8",
+                                            "-target",
+                                            "8",
+                                            "-Xlint:-options"),
+                                    null,
+                                    fileManager.getJavaFileObjects(
+                                            oldFormat.toFile(),
+                                            oldPredicate.toFile(),
+                                            oldCoreOptions.toFile(),
+                                            oldFileIndexOptions.toFile(),
+                                            legacyClient.toFile()))
+                            .call();
+            assertThat(compiled).isTrue();
+        }
+
+        URL[] urls = {classesDirectory.toUri().toURL()};
+        try (URLClassLoader classLoader =
+                new URLClassLoader(urls, FileIndexFormat.class.getClassLoader())) {
+            return classLoader.loadClass(
+                    "org.apache.paimon.fileindex.compatible.LegacyFileIndexFormatClient");
+        }
+    }
+}
diff --git a/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexFormatFormatTest.java b/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexFormatFormatTest.java
index c6ce94c89a..fdea151ad1 100644
--- a/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexFormatFormatTest.java
+++ b/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexFormatFormatTest.java
@@ -18,34 +18,46 @@
 
 package org.apache.paimon.fileindex;
 
+import org.apache.paimon.fileindex.bitmap.BitmapFileIndex;
+import org.apache.paimon.fileindex.bitmap.BitmapIndexResult;
 import org.apache.paimon.fileindex.empty.EmptyFileIndexReader;
 import org.apache.paimon.fs.ByteArraySeekableStream;
 import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.RoaringBitmap32;
 
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashMap;
-import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Random;
+import java.util.TreeMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import static org.apache.paimon.utils.RandomUtil.randomBytes;
 import static org.apache.paimon.utils.RandomUtil.randomString;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.api.Assertions.tuple;
 
 /** Test for {@link FileIndexFormat}. */
 public class FileIndexFormatFormatTest {
 
     private static final Random RANDOM = new Random();
+    private static final RowType ROW_TYPE = RowType.of(DataTypes.INT());
 
     @Test
     public void testCreateReaderClosesStreamOnBadMagic() {
@@ -61,7 +73,9 @@ public class FileIndexFormatFormatTest {
                 };
         Throwable thrown =
                 Assertions.catchThrowable(
-                        () -> FileIndexFormat.createReader(counting, RowType.builder().build()));
+                        () ->
+                                FileIndexFormat.createReader(
+                                        counting, RowType.builder().build(), notIndexFile.length));
         // A throwing constructor never assigns the caller's try-with-resources resource,
         // so closing the stream is the constructor's job.
         Assertions.assertThat(closed.get()).isTrue();
@@ -73,8 +87,6 @@ public class FileIndexFormatFormatTest {
     @Test
     public void testWriteRead() throws IOException {
         ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        FileIndexFormat.Writer writer = FileIndexFormat.createWriter(baos);
-
         Map<String, Map<String, byte[]>> indexes = new HashMap<>();
         for (int j = 0; j < RANDOM.nextInt(1000); j++) {
             String type = randomString(RANDOM.nextInt(100));
@@ -85,22 +97,33 @@ public class FileIndexFormatFormatTest {
             }
         }
 
-        writer.writeColumnIndexes(indexes);
-        writer.close();
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(baos, 1)) {
+            for (Map.Entry<String, Map<String, byte[]>> column : indexes.entrySet()) {
+                for (Map.Entry<String, byte[]> index : column.getValue().entrySet()) {
+                    writer.writeIndex(
+                            column.getKey(),
+                            index.getKey(),
+                            output -> output.write(index.getValue()));
+                }
+            }
+            writer.finish();
+        }
 
         byte[] indexBytes = baos.toByteArray();
 
         FileIndexFormat.Reader reader =
                 FileIndexFormat.createReader(
-                        new ByteArraySeekableStream(indexBytes), RowType.builder().build());
+                        new ByteArraySeekableStream(indexBytes),
+                        RowType.builder().build(),
+                        indexBytes.length);
 
         for (Map.Entry<String, Map<String, byte[]>> entry : indexes.entrySet()) {
             String column = entry.getKey();
             for (String type : entry.getValue().keySet()) {
-                byte[] b =
-                        reader.getBytesWithNameAndType(column, type)
-                                .orElseThrow(RuntimeException::new);
-                Assertions.assertThat(b).containsExactly(indexes.get(column).get(type));
+                ByteArrayOutputStream copied = new ByteArrayOutputStream();
+                reader.copyPayload(column, type, copied);
+                Assertions.assertThat(copied.toByteArray())
+                        .containsExactly(indexes.get(column).get(type));
             }
         }
     }
@@ -108,15 +131,11 @@ public class FileIndexFormatFormatTest {
     @Test
     public void testEmptyFileIndex() throws IOException {
         ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        FileIndexFormat.Writer writer = FileIndexFormat.createWriter(baos);
-
-        Map<String, Map<String, byte[]>> indexes = new HashMap<>();
-
-        indexes.computeIfAbsent("a", a -> new HashMap<>()).put("b", null);
-        indexes.computeIfAbsent("a", a -> new HashMap<>()).put("c", null);
-
-        writer.writeColumnIndexes(indexes);
-        writer.close();
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(baos, 1)) {
+            writer.writeIndex("a", "b", null);
+            writer.writeIndex("a", "c", null);
+            writer.finish();
+        }
 
         byte[] indexBytes = baos.toByteArray();
 
@@ -126,7 +145,8 @@ public class FileIndexFormatFormatTest {
                         RowType.builder()
                                 .field("a", DataTypes.BYTES())
                                 .field("b", DataTypes.STRING())
-                                .build());
+                                .build(),
+                        indexBytes.length);
 
         Collection<FileIndexReader> fileIndexFormatList = reader.readColumnIndex("a");
         Assertions.assertThat(fileIndexFormatList.size()).isEqualTo(1);
@@ -137,21 +157,17 @@ public class FileIndexFormatFormatTest {
     @Test
     public void testIndexMetas() throws IOException {
         ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        Map<String, Map<String, byte[]>> indexes = new LinkedHashMap<>();
-        indexes.computeIfAbsent("user_id", key -> new LinkedHashMap<>())
-                .put("bitmap", new byte[] {1, 2, 3});
-        indexes.computeIfAbsent("user_id", key -> new LinkedHashMap<>())
-                .put("bloom-filter", new byte[] {4, 5});
-        indexes.computeIfAbsent("region", key -> new LinkedHashMap<>()).put("bitmap", null);
-
-        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(baos)) {
-            writer.writeColumnIndexes(indexes);
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(baos, 1)) {
+            writer.writeIndex("user_id", "bitmap", output -> output.write(new byte[] {1, 2, 3}));
+            writer.writeIndex("user_id", "bloom-filter", output -> output.write(new byte[] {4, 5}));
+            writer.writeIndex("region", "bitmap", null);
+            writer.finish();
         }
 
         List<FileIndexFormat.FileIndexMeta> metas;
         try (FileIndexFormat.Reader reader =
                 FileIndexFormat.createMetadataReader(
-                        new ByteArraySeekableStream(baos.toByteArray()))) {
+                        new ByteArraySeekableStream(baos.toByteArray()), baos.size())) {
             metas = reader.indexMetas();
         }
 
@@ -159,13 +175,249 @@ public class FileIndexFormatFormatTest {
                 .extracting(
                         FileIndexFormat.FileIndexMeta::columnName,
                         FileIndexFormat.FileIndexMeta::indexType,
-                        FileIndexFormat.FileIndexMeta::sizeInBytes,
+                        FileIndexFormat.FileIndexMeta::sizeInBytesLong,
                         FileIndexFormat.FileIndexMeta::empty)
                 .containsExactlyInAnyOrder(
-                        tuple("user_id", "bitmap", 3, false),
-                        tuple("user_id", "bloom-filter", 2, false),
-                        tuple("region", "bitmap", 0, true));
+                        tuple("user_id", "bitmap", 3L, false),
+                        tuple("user_id", "bloom-filter", 2L, false),
+                        tuple("region", "bitmap", 0L, true));
         Assertions.assertThatThrownBy(() -> metas.clear())
                 .isInstanceOf(UnsupportedOperationException.class);
     }
+
+    @Test
+    public void testV2TotalSizeOverTwoGiBAndLegacyBackendAtLargePosition() throws Exception {
+        SparseOutput output = new SparseOutput();
+        byte[] payload = new byte[1024 * 1024];
+        BitmapFileIndex indexer = new BitmapFileIndex(DataTypes.INT(), new Options());
+        FileIndexWriter bitmap = indexer.createWriter();
+        bitmap.writeRecord(42);
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(output, 2)) {
+            for (int i = 0; i < 2049; i++) {
+                writer.writeIndex(
+                        "large", String.format("part%04d", i), stream -> stream.write(payload));
+            }
+            writer.writeIndex("f0", "bitmap", bitmap::writeTo);
+            writer.finish();
+        }
+        try (FileIndexFormat.Reader reader =
+                FileIndexFormat.createReader(output.input(), ROW_TYPE, output.position)) {
+            assertThat(output.position).isGreaterThan(Integer.MAX_VALUE);
+            FileIndexReader bitmapReader = reader.readColumnIndex("f0").iterator().next();
+            assertThat(
+                            ((BitmapIndexResult)
+                                            bitmapReader.visitEqual(
+                                                    new FieldRef(0, "f0", DataTypes.INT()), 42))
+                                    .get())
+                    .isEqualTo(RoaringBitmap32.bitmapOf(0));
+        }
+    }
+
+    @Test
+    public void testV2StreamedPayloadOverInt32() throws Exception {
+        SparseOutput output = new SparseOutput();
+        byte[] block = new byte[1024 * 1024];
+        BitmapFileIndex indexer = new BitmapFileIndex(DataTypes.INT(), new Options());
+        FileIndexWriter bitmap = indexer.createWriter();
+        bitmap.writeRecord(42);
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(output, 2)) {
+            writer.writeIndex(
+                    "large",
+                    "bitmap",
+                    stream -> {
+                        for (int i = 0; i < 2049; i++) {
+                            stream.write(block);
+                        }
+                    });
+            writer.writeIndex("f0", "bitmap", bitmap::writeTo);
+            writer.finish();
+        }
+
+        RowType rowType =
+                RowType.builder()
+                        .field("large", DataTypes.INT())
+                        .field("f0", DataTypes.INT())
+                        .build();
+        try (FileIndexFormat.Reader reader =
+                FileIndexFormat.createReader(output.input(), rowType, output.position)) {
+            assertThat(reader.indexMetas())
+                    .extracting(FileIndexFormat.FileIndexMeta::sizeInBytesLong)
+                    .contains((long) block.length * 2049);
+            FileIndexReader bitmapReader = reader.readColumnIndex("f0").iterator().next();
+            assertThat(
+                            ((BitmapIndexResult)
+                                            bitmapReader.visitEqual(
+                                                    new FieldRef(1, "f0", DataTypes.INT()), 42))
+                                    .get())
+                    .isEqualTo(RoaringBitmap32.bitmapOf(0));
+        }
+    }
+
+    @Test
+    public void testV2CopyPayloadOverInt32() throws Exception {
+        SparseOutput source = new SparseOutput();
+        byte[] block = new byte[1024 * 1024];
+        long payloadLength = (long) block.length * 2049 + 1;
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(source, 2)) {
+            writer.writeIndex(
+                    "large",
+                    "bitmap",
+                    stream -> {
+                        for (int i = 0; i < 2049; i++) {
+                            stream.write(block);
+                        }
+                        stream.write(7);
+                    });
+            writer.finish();
+        }
+
+        try (FileIndexFormat.Reader reader =
+                FileIndexFormat.createMetadataReader(source.input(), source.position)) {
+            final long[] copied = {0};
+            final int[] lastByte = {-1};
+            reader.copyPayload(
+                    "large",
+                    "bitmap",
+                    new OutputStream() {
+                        @Override
+                        public void write(int value) {
+                            copied[0]++;
+                            lastByte[0] = value;
+                        }
+
+                        @Override
+                        public void write(byte[] bytes, int offset, int length) {
+                            copied[0] += length;
+                            lastByte[0] = bytes[offset + length - 1] & 0xff;
+                        }
+                    });
+            assertThat(copied[0]).isEqualTo(payloadLength);
+            assertThat(lastByte[0]).isEqualTo(7);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {1, 2})
+    public void testIncrementalWriteAndCopyPayload(int version) throws Exception {
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(output, version)) {
+            writer.writeIndex("a", "bitmap", stream -> stream.write(new byte[] {1, 2, 3}));
+            writer.writeIndex("a", "bsi", null);
+            writer.writeIndex("b", "bitmap", stream -> {});
+            writer.finish();
+        }
+        try (FileIndexFormat.Reader reader =
+                FileIndexFormat.createMetadataReader(
+                        new ByteArraySeekableStream(output.toByteArray()), output.size())) {
+            ByteArrayOutputStream copied = new ByteArrayOutputStream();
+            reader.copyPayload("a", "bitmap", copied);
+            assertThat(copied.toByteArray()).containsExactly(1, 2, 3);
+            assertThat(reader.indexMetas())
+                    .extracting(FileIndexFormat.FileIndexMeta::empty)
+                    .containsExactlyInAnyOrder(false, true, false);
+        }
+    }
+
+    @Test
+    public void testV2RejectInvalidTailMagic() throws Exception {
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(output, 2)) {
+            writer.writeIndex("f0", "bitmap", stream -> stream.write(new byte[] {1, 2, 3}));
+            writer.finish();
+        }
+        byte[] bytes = output.toByteArray();
+        bytes[bytes.length - 1] = 0;
+        assertThatThrownBy(
+                        () ->
+                                FileIndexFormat.createReader(
+                                        new ByteArraySeekableStream(bytes), ROW_TYPE, bytes.length))
+                .rootCause()
+                .hasMessageContaining("Invalid file index tail magic");
+    }
+
+    /**
+     * Retains small metadata writes while large zero-filled payload writes only advance position.
+     */
+    private static class SparseOutput extends OutputStream {
+        private final TreeMap<Long, byte[]> segments = new TreeMap<>();
+        private long position;
+
+        @Override
+        public void write(int value) {
+            write(new byte[] {(byte) value}, 0, 1);
+        }
+
+        @Override
+        public void write(byte[] bytes, int offset, int length) {
+            if (length < 1024) {
+                segments.put(position, Arrays.copyOfRange(bytes, offset, offset + length));
+            }
+            position += length;
+        }
+
+        private SeekableInputStream input() {
+            return new SeekableInputStream() {
+                private long cursor;
+
+                @Override
+                public void seek(long desired) {
+                    cursor = desired;
+                }
+
+                @Override
+                public long getPos() {
+                    return cursor;
+                }
+
+                @Override
+                public int read() {
+                    if (cursor >= position) {
+                        return -1;
+                    }
+                    Map.Entry<Long, byte[]> entry = segments.floorEntry(cursor);
+                    int value =
+                            entry != null && cursor - entry.getKey() < entry.getValue().length
+                                    ? entry.getValue()[(int) (cursor - entry.getKey())] & 0xff
+                                    : 0;
+                    cursor++;
+                    return value;
+                }
+
+                @Override
+                public int read(byte[] b, int off, int len) {
+                    if (len == 0) {
+                        return 0;
+                    }
+                    if (cursor >= position) {
+                        return -1;
+                    }
+                    int n = (int) Math.min(len, position - cursor);
+                    long end = cursor + n;
+                    Arrays.fill(b, off, off + n, (byte) 0);
+                    Map.Entry<Long, byte[]> entry = segments.floorEntry(cursor);
+                    if (entry == null) {
+                        entry = segments.ceilingEntry(cursor);
+                    }
+                    while (entry != null && entry.getKey() < end) {
+                        long overlapStart = Math.max(cursor, entry.getKey());
+                        long overlapEnd = Math.min(end, entry.getKey() + entry.getValue().length);
+                        if (overlapStart < overlapEnd) {
+                            System.arraycopy(
+                                    entry.getValue(),
+                                    (int) (overlapStart - entry.getKey()),
+                                    b,
+                                    off + (int) (overlapStart - cursor),
+                                    (int) (overlapEnd - overlapStart));
+                        }
+                        entry = segments.higherEntry(entry.getKey());
+                    }
+                    cursor = end;
+                    return n;
+                }
+
+                @Override
+                public void close() {}
+            };
+        }
+    }
 }
diff --git a/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexPredicateCloseTest.java b/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexPredicateCloseTest.java
index 9e84dc7d62..31d4ca2103 100644
--- a/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexPredicateCloseTest.java
+++ b/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexPredicateCloseTest.java
@@ -19,23 +19,76 @@
 package org.apache.paimon.fileindex;
 
 import org.apache.paimon.fs.ByteArraySeekableStream;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.FileStatus;
+import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.SeekableInputStream;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
 
 import org.junit.jupiter.api.Test;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.concurrent.atomic.AtomicInteger;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 /** Tests that {@link FileIndexPredicate} always releases the stream it is handed. */
 public class FileIndexPredicateCloseTest {
 
     private static final RowType ROW_TYPE = RowType.of(DataTypes.INT());
 
+    @Test
+    public void testV1DoesNotLoadFileStatus() throws IOException {
+        FileIO fileIO = mock(FileIO.class);
+        Path path = new Path("file:/index");
+        when(fileIO.newInputStream(path)).thenReturn(new ByteArraySeekableStream(container(1)));
+
+        try (FileIndexPredicate ignored = new FileIndexPredicate(path, fileIO, ROW_TYPE)) {
+            verify(fileIO).newInputStream(path);
+            verify(fileIO, never()).getFileStatus(path);
+        }
+    }
+
+    @Test
+    public void testV2LoadsFileStatus() throws IOException {
+        FileIO fileIO = mock(FileIO.class);
+        Path path = new Path("file:/index");
+        byte[] container = container(2);
+        FileStatus fileStatus = mock(FileStatus.class);
+        when(fileIO.newInputStream(path)).thenReturn(new ByteArraySeekableStream(container));
+        when(fileIO.getFileStatus(path)).thenReturn(fileStatus);
+        when(fileStatus.getLen()).thenReturn((long) container.length);
+
+        try (FileIndexPredicate ignored = new FileIndexPredicate(path, fileIO, ROW_TYPE)) {
+            verify(fileIO).newInputStream(path);
+            verify(fileIO).getFileStatus(path);
+        }
+    }
+
+    @Test
+    public void testV2FileStatusFailureReleasesStream() throws IOException {
+        FileIO fileIO = mock(FileIO.class);
+        Path path = new Path("file:/index");
+        AtomicInteger closed = new AtomicInteger();
+        when(fileIO.newInputStream(path)).thenReturn(tracking(container(2), closed));
+        IOException exception = new IOException("Failed to get file status");
+        when(fileIO.getFileStatus(path)).thenThrow(exception);
+
+        assertThatThrownBy(() -> new FileIndexPredicate(path, fileIO, ROW_TYPE))
+                .isSameAs(exception);
+
+        verify(fileIO).newInputStream(path);
+        verify(fileIO).getFileStatus(path);
+        assertThat(closed).hasValue(1);
+    }
+
     /**
      * The reader rejects a file whose magic does not match, and at that point nothing else holds a
      * reference to the stream, so the constructor has to release it itself.
@@ -45,7 +98,12 @@ public class FileIndexPredicateCloseTest {
         AtomicInteger closed = new AtomicInteger();
         byte[] notAnIndexFile = new byte[64];
 
-        assertThatThrownBy(() -> new FileIndexPredicate(tracking(notAnIndexFile, closed), ROW_TYPE))
+        assertThatThrownBy(
+                        () ->
+                                new FileIndexPredicate(
+                                        tracking(notAnIndexFile, closed),
+                                        ROW_TYPE,
+                                        notAnIndexFile.length))
                 .isInstanceOf(RuntimeException.class)
                 .hasMessageContaining("Exception happens while construct file index reader.")
                 .hasRootCauseMessage("This file is not file index file.");
@@ -84,4 +142,12 @@ public class FileIndexPredicateCloseTest {
             }
         };
     }
+
+    private static byte[] container(int version) throws IOException {
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(output, version)) {
+            writer.finish();
+        }
+        return output.toByteArray();
+    }
 }
diff --git a/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexPredicateTest.java b/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexPredicateTest.java
index e599c354ab..35e42ad14f 100644
--- a/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexPredicateTest.java
+++ b/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexPredicateTest.java
@@ -36,8 +36,6 @@ import java.io.ByteArrayOutputStream;
 import java.lang.reflect.Method;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Set;
 
 import static org.apache.paimon.fileindex.FileIndexResult.REMAIN;
@@ -67,13 +65,10 @@ public class FileIndexPredicateTest {
                 new BloomFilterFileIndex(DataTypes.DOUBLE(), new Options()).createWriter();
         indexWriter.writeRecord(Double.NaN);
 
-        Map<String, Map<String, byte[]>> indexes = new HashMap<>();
-        indexes.computeIfAbsent("d", column -> new HashMap<>())
-                .put(BLOOM_FILTER, indexWriter.serializedBytes());
-
         ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(baos)) {
-            writer.writeColumnIndexes(indexes);
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(baos, 1)) {
+            writer.writeIndex("d", BLOOM_FILTER, indexWriter::writeTo);
+            writer.finish();
         }
 
         try (FileIndexPredicate predicate = new FileIndexPredicate(baos.toByteArray(), rowType)) {
@@ -91,9 +86,9 @@ public class FileIndexPredicateTest {
 
     private static FileIndexPredicate emptyFileIndexPredicate() throws Exception {
         ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        FileIndexFormat.Writer writer = FileIndexFormat.createWriter(baos);
-        writer.writeColumnIndexes(new HashMap<String, java.util.Map<String, byte[]>>());
-        writer.close();
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(baos, 1)) {
+            writer.finish();
+        }
         return new FileIndexPredicate(baos.toByteArray(), RowType.builder().build());
     }
 
diff --git a/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexerCompatibilityTest.java b/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexerCompatibilityTest.java
new file mode 100644
index 0000000000..83045a8fb7
--- /dev/null
+++ b/paimon-common/src/test/java/org/apache/paimon/fileindex/FileIndexerCompatibilityTest.java
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.fileindex;
+
+import org.apache.paimon.fs.ByteArraySeekableStream;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import javax.tools.JavaCompiler;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.ToolProvider;
+
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Field;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Binary compatibility tests for {@link FileIndexer}. */
+public class FileIndexerCompatibilityTest {
+
+    private static final String LEGACY_INDEX = "legacy-binary";
+
+    @TempDir private Path temporaryDirectory;
+
+    @Test
+    public void testLegacyBinaryReadsV1Container() throws Exception {
+        FileIndexer legacyIndexer = compileLegacyIndexer();
+        FileIndexerFactory legacyFactory =
+                new FileIndexerFactory() {
+                    @Override
+                    public String identifier() {
+                        return LEGACY_INDEX;
+                    }
+
+                    @Override
+                    public FileIndexer create(DataType type, Options options) {
+                        return legacyIndexer;
+                    }
+                };
+
+        Field factoriesField = FileIndexerFactoryUtils.class.getDeclaredField("factories");
+        factoriesField.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        Map<String, FileIndexerFactory> factories =
+                (Map<String, FileIndexerFactory>) factoriesField.get(null);
+        FileIndexerFactory previous = factories.put(LEGACY_INDEX, legacyFactory);
+        try {
+            ByteArrayOutputStream output = new ByteArrayOutputStream();
+            try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(output, 1)) {
+                writer.writeIndex("f0", LEGACY_INDEX, stream -> stream.write(42));
+                writer.finish();
+            }
+
+            byte[] bytes = output.toByteArray();
+            RowType rowType = RowType.builder().field("f0", DataTypes.INT()).build();
+            try (FileIndexFormat.Reader reader =
+                    FileIndexFormat.createReader(
+                            new ByteArraySeekableStream(bytes), rowType, bytes.length)) {
+                assertThat(reader.readColumnIndex("f0")).hasSize(1);
+            }
+        } finally {
+            if (previous == null) {
+                factories.remove(LEGACY_INDEX);
+            } else {
+                factories.put(LEGACY_INDEX, previous);
+            }
+        }
+    }
+
+    private FileIndexer compileLegacyIndexer() throws Exception {
+        Path sourceDirectory = temporaryDirectory.resolve("source");
+        Path classesDirectory = temporaryDirectory.resolve("classes");
+        Path oldInterface = sourceDirectory.resolve("org/apache/paimon/fileindex/FileIndexer.java");
+        Path legacyImplementation =
+                sourceDirectory.resolve(
+                        "org/apache/paimon/fileindex/compatible/LegacyFileIndexer.java");
+        Files.createDirectories(oldInterface.getParent());
+        Files.createDirectories(legacyImplementation.getParent());
+        Files.createDirectories(classesDirectory);
+
+        Files.write(
+                oldInterface,
+                Arrays.asList(
+                        "package org.apache.paimon.fileindex;",
+                        "import org.apache.paimon.fs.SeekableInputStream;",
+                        "public interface FileIndexer {",
+                        "  FileIndexWriter createWriter();",
+                        "  FileIndexReader createReader(",
+                        "      SeekableInputStream inputStream, int start, int length);",
+                        "}"),
+                StandardCharsets.UTF_8);
+        Files.write(
+                legacyImplementation,
+                Arrays.asList(
+                        "package org.apache.paimon.fileindex.compatible;",
+                        "import org.apache.paimon.fileindex.FileIndexReader;",
+                        "import org.apache.paimon.fileindex.FileIndexer;",
+                        "import org.apache.paimon.fileindex.FileIndexWriter;",
+                        "import org.apache.paimon.fs.SeekableInputStream;",
+                        "public class LegacyFileIndexer implements FileIndexer {",
+                        "  public FileIndexWriter createWriter() { return null; }",
+                        "  public FileIndexReader createReader(",
+                        "      SeekableInputStream input, int start, int length) {",
+                        "    try {",
+                        "      input.seek(start);",
+                        "      if (length != 1 || input.read() != 42) {",
+                        "        throw new RuntimeException(\"Unexpected legacy payload\");",
+                        "      }",
+                        "      return new FileIndexReader() {};",
+                        "    } catch (java.io.IOException e) {",
+                        "      throw new RuntimeException(e);",
+                        "    }",
+                        "  }",
+                        "}"),
+                StandardCharsets.UTF_8);
+
+        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
+        assertThat(compiler).as("A JDK compiler is required").isNotNull();
+        try (StandardJavaFileManager fileManager =
+                compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) {
+            boolean compiled =
+                    compiler.getTask(
+                                    null,
+                                    fileManager,
+                                    null,
+                                    Arrays.asList(
+                                            "-classpath",
+                                            System.getProperty("java.class.path"),
+                                            "-d",
+                                            classesDirectory.toString(),
+                                            "-source",
+                                            "8",
+                                            "-target",
+                                            "8",
+                                            "-Xlint:-options"),
+                                    null,
+                                    fileManager.getJavaFileObjects(
+                                            oldInterface.toFile(), legacyImplementation.toFile()))
+                            .call();
+            assertThat(compiled).isTrue();
+        }
+
+        URL[] urls = {classesDirectory.toUri().toURL()};
+        try (URLClassLoader classLoader =
+                new URLClassLoader(urls, FileIndexer.class.getClassLoader())) {
+            Class<?> legacyClass =
+                    classLoader.loadClass(
+                            "org.apache.paimon.fileindex.compatible.LegacyFileIndexer");
+            assertThat(
+                            legacyClass.getDeclaredMethod(
+                                    "createReader",
+                                    org.apache.paimon.fs.SeekableInputStream.class,
+                                    int.class,
+                                    int.class))
+                    .isNotNull();
+            return (FileIndexer) legacyClass.getDeclaredConstructor().newInstance();
+        }
+    }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/index/FileIndexProcessor.java b/paimon-core/src/main/java/org/apache/paimon/index/FileIndexProcessor.java
index 3030ac3bb2..21dfd98300 100644
--- a/paimon-core/src/main/java/org/apache/paimon/index/FileIndexProcessor.java
+++ b/paimon-core/src/main/java/org/apache/paimon/index/FileIndexProcessor.java
@@ -28,6 +28,7 @@ import org.apache.paimon.fs.Path;
 import org.apache.paimon.io.DataFileIndexWriter;
 import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.io.DataFilePathFactory;
+import org.apache.paimon.io.SpillableIndexOutputStream;
 import org.apache.paimon.manifest.ManifestEntry;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.reader.RecordReader;
@@ -42,9 +43,7 @@ import org.apache.paimon.utils.FileStorePathFactory;
 
 import javax.annotation.Nullable;
 
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -67,7 +66,7 @@ public class FileIndexProcessor {
     private final FileStorePathFactory pathFactory;
     private final DataFilePathFactories pathFactories;
     private final SchemaCache schemaInfoCache;
-    private final long sizeInMeta;
+    private final int sizeInMeta;
 
     public FileIndexProcessor(FileStoreTable table) {
         this.table = table;
@@ -76,7 +75,7 @@ public class FileIndexProcessor {
         this.pathFactory = table.store().pathFactory();
         this.pathFactories = new DataFilePathFactories(pathFactory);
         this.schemaInfoCache = new SchemaCache(fileIndexOptions, table.schemaManager());
-        this.sizeInMeta = table.coreOptions().fileIndexInManifestThreshold();
+        this.sizeInMeta = Math.toIntExact(table.coreOptions().fileIndexInManifestThreshold());
     }
 
     public DataFileMeta process(BinaryRow partition, int bucket, ManifestEntry manifestEntry)
@@ -90,102 +89,113 @@ public class FileIndexProcessor {
                         .filter(name -> name.endsWith(DataFilePathFactory.INDEX_PATH_SUFFIX))
                         .collect(Collectors.toList());
         extras.removeAll(indexFiles);
-
         Path newIndexPath;
-        Map<String, Map<String, byte[]>> maintainers;
-        // load
+        FileIndexFormat.Reader sourceReader = null;
         if (!indexFiles.isEmpty()) {
-            String indexFile = indexFiles.get(0);
-            try (FileIndexFormat.Reader indexReader =
+            Path sourcePath = dataFilePathFactory.toAlignedPath(indexFiles.get(0), dataFileMeta);
+            long sourceLength = fileIO.getFileStatus(sourcePath).getLen();
+            sourceReader =
                     FileIndexFormat.createReader(
-                            fileIO.newInputStream(
-                                    dataFilePathFactory.toAlignedPath(indexFile, dataFileMeta)),
-                            schemaInfo.fileSchema)) {
-                maintainers = indexReader.readAll();
-            }
-            newIndexPath =
-                    createNewFileIndexFilePath(
-                            dataFilePathFactory.toAlignedPath(indexFile, dataFileMeta));
+                            fileIO.newInputStream(sourcePath), schemaInfo.fileSchema, sourceLength);
+            newIndexPath = createNewFileIndexFilePath(sourcePath);
         } else {
-            maintainers = new HashMap<>();
             newIndexPath = dataFileToFileIndexPath(dataFilePathFactory.toPath(dataFileMeta));
         }
 
-        // remove unnecessary
-        for (Map.Entry<String, Map<String, byte[]>> entry : new HashSet<>(maintainers.entrySet())) {
-            String name = entry.getKey();
-            if (!schemaInfo.projectedColFullNames.contains(name)) {
-                maintainers.remove(name);
-            } else {
-                Map<String, byte[]> indexTypeBytes = maintainers.get(name);
-                Set<String> configuredIndexTypes =
-                        schemaInfo.projectedIndexTypes.getOrDefault(name, Collections.emptySet());
-                indexTypeBytes
-                        .keySet()
-                        .removeIf(indexType -> !configuredIndexTypes.contains(indexType));
+        try (FileIndexFormat.Reader oldReader = sourceReader) {
+            Map<String, Map<String, FileIndexFormat.Payload>> entries = new HashMap<>();
+            if (oldReader != null) {
+                for (FileIndexFormat.FileIndexMeta meta : oldReader.indexMetas()) {
+                    String column = meta.columnName();
+                    String type = meta.indexType();
+                    if (schemaInfo.projectedColFullNames.contains(column)
+                            && schemaInfo
+                                    .projectedIndexTypes
+                                    .getOrDefault(column, Collections.emptySet())
+                                    .contains(type)) {
+                        entries.computeIfAbsent(column, ignored -> new HashMap<>())
+                                .put(
+                                        type,
+                                        meta.empty()
+                                                ? null
+                                                : output ->
+                                                        oldReader.copyPayload(
+                                                                column, type, output));
+                    }
+                }
             }
-        }
 
-        // ignore close, do not close to write file, only collect serialized maintainers
-        @SuppressWarnings("resource")
-        DataFileIndexWriter dataFileIndexWriter =
-                DataFileIndexWriter.create(
-                        fileIO,
-                        newIndexPath,
-                        schemaInfo.fileSchema.project(schemaInfo.projectedIndexCols),
-                        fileIndexOptions,
-                        schemaInfo.colNameMapping);
-        if (dataFileIndexWriter != null) {
-            // projectedIndexCols index into the file schema. withProjection would re-interpret
-            // them against the current table schema, so a schema change that shifts columns (drop
-            // a middle column, add another) would read the wrong column and rebuild the index over
-            // it. Read with the same file-schema projection the writer above uses.
-            RowType indexReadType = schemaInfo.fileSchema.project(schemaInfo.projectedIndexCols);
-            try (RecordReader<InternalRow> reader =
-                    table.newReadBuilder()
-                            .withReadType(indexReadType)
-                            .newRead()
-                            .createReader(
-                                    DataSplit.builder()
-                                            .withPartition(partition)
-                                            .withBucket(bucket)
-                                            .withBucketPath(
-                                                    pathFactory
-                                                            .bucketPath(partition, bucket)
-                                                            .toString())
-                                            .withTotalBuckets(manifestEntry.totalBuckets())
-                                            .withDataFiles(Collections.singletonList(dataFileMeta))
-                                            .rawConvertible(true)
-                                            .build())) {
-                reader.forEachRemaining(dataFileIndexWriter::write);
+            // Collect the new writers, not their serialized payloads.
+            @SuppressWarnings("resource")
+            DataFileIndexWriter newIndexes =
+                    DataFileIndexWriter.create(
+                            fileIO,
+                            newIndexPath,
+                            schemaInfo.fileSchema.project(schemaInfo.projectedIndexCols),
+                            fileIndexOptions,
+                            schemaInfo.colNameMapping);
+            if (newIndexes != null) {
+                // projectedIndexCols index into the file schema. withProjection would re-interpret
+                // them against the current table schema, so a schema change that shifts columns
+                // would rebuild the index over the wrong column.
+                RowType indexReadType =
+                        schemaInfo.fileSchema.project(schemaInfo.projectedIndexCols);
+                try (RecordReader<InternalRow> reader =
+                        table.newReadBuilder()
+                                .withReadType(indexReadType)
+                                .newRead()
+                                .createReader(
+                                        DataSplit.builder()
+                                                .withPartition(partition)
+                                                .withBucket(bucket)
+                                                .withBucketPath(
+                                                        pathFactory
+                                                                .bucketPath(partition, bucket)
+                                                                .toString())
+                                                .withTotalBuckets(manifestEntry.totalBuckets())
+                                                .withDataFiles(
+                                                        Collections.singletonList(dataFileMeta))
+                                                .rawConvertible(true)
+                                                .build())) {
+                    reader.forEachRemaining(newIndexes::write);
+                }
+                newIndexes.forEachIndex(
+                        (column, type, writer) ->
+                                entries.computeIfAbsent(column, ignored -> new HashMap<>())
+                                        .put(type, writer == null ? null : writer::writeTo));
             }
 
-            dataFileIndexWriter
-                    .serializeMaintainers()
-                    .forEach(
-                            (key, value) ->
-                                    maintainers
-                                            .computeIfAbsent(key, k -> new HashMap<>())
-                                            .putAll(value));
-        }
-
-        ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        try (FileIndexFormat.Writer indexWriter = FileIndexFormat.createWriter(baos)) {
-            if (!maintainers.isEmpty()) {
-                indexWriter.writeColumnIndexes(maintainers);
+            if (entries.isEmpty()) {
+                return dataFileMeta.copy(extras).copy((byte[]) null);
             }
-        }
 
-        if (baos.size() > sizeInMeta) {
-            try (OutputStream outputStream = fileIO.newOutputStream(newIndexPath, true)) {
-                outputStream.write(baos.toByteArray());
+            SpillableIndexOutputStream output =
+                    new SpillableIndexOutputStream(fileIO, newIndexPath, sizeInMeta);
+            try {
+                try (FileIndexFormat.Writer writer =
+                        FileIndexFormat.createWriter(output, fileIndexOptions.formatVersion())) {
+                    for (Map.Entry<String, Map<String, FileIndexFormat.Payload>> column :
+                            entries.entrySet()) {
+                        for (Map.Entry<String, FileIndexFormat.Payload> index :
+                                column.getValue().entrySet()) {
+                            writer.writeIndex(column.getKey(), index.getKey(), index.getValue());
+                        }
+                    }
+                    writer.finish();
+                }
+                if (output.spilled()) {
+                    extras.add(newIndexPath.getName());
+                    return dataFileMeta.copy(extras).copy((byte[]) null);
+                }
+                return dataFileMeta.copy(extras).copy(output.embeddedBytes());
+            } catch (IOException | RuntimeException e) {
+                try {
+                    output.abort();
+                } catch (IOException cleanupFailure) {
+                    e.addSuppressed(cleanupFailure);
+                }
+                throw e;
             }
-            extras.add(newIndexPath.getName());
-            return dataFileMeta.copy(extras).copy((byte[]) null);
-        } else if (baos.size() == 0) {
-            return dataFileMeta.copy(extras).copy((byte[]) null);
-        } else {
-            return dataFileMeta.copy(extras).copy(baos.toByteArray());
         }
     }
 
diff --git a/paimon-core/src/main/java/org/apache/paimon/io/DataFileIndexWriter.java b/paimon-core/src/main/java/org/apache/paimon/io/DataFileIndexWriter.java
index 2c620dd143..6c122b1a44 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/DataFileIndexWriter.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/DataFileIndexWriter.java
@@ -37,11 +37,8 @@ import org.apache.paimon.types.RowType;
 
 import javax.annotation.Nullable;
 
-import java.io.ByteArrayOutputStream;
 import java.io.Closeable;
 import java.io.IOException;
-import java.io.OutputStream;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -58,7 +55,8 @@ public final class DataFileIndexWriter implements Closeable {
     private final Path path;
 
     // if the filter size greater than fileIndexInManifestThreshold, we put it in file
-    private final long inManifestThreshold;
+    private final int inManifestThreshold;
+    private final int formatVersion;
 
     // index type, column name -> index maintainer
     private final Map<String, Map<String, IndexMaintainer>> indexMaintainers = new HashMap<>();
@@ -145,7 +143,8 @@ public final class DataFileIndexWriter implements Closeable {
                 }
             }
         }
-        this.inManifestThreshold = fileIndexOptions.fileIndexInManifestThreshold();
+        this.inManifestThreshold = Math.toIntExact(fileIndexOptions.fileIndexInManifestThreshold());
+        this.formatVersion = fileIndexOptions.formatVersion();
     }
 
     public void write(InternalRow row) {
@@ -158,36 +157,57 @@ public final class DataFileIndexWriter implements Closeable {
 
     @Override
     public void close() throws IOException {
-        Map<String, Map<String, byte[]>> indexMaps = serializeMaintainers();
-
-        ByteArrayOutputStream out = new ByteArrayOutputStream();
-        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(out)) {
-            writer.writeColumnIndexes(indexMaps);
-        }
-
-        if (out.size() > inManifestThreshold) {
-            try (OutputStream outputStream = fileIO.newOutputStream(path, true)) {
-                outputStream.write(out.toByteArray());
+        SpillableIndexOutputStream output =
+                new SpillableIndexOutputStream(fileIO, path, inManifestThreshold);
+        try {
+            try (FileIndexFormat.Writer writer =
+                    FileIndexFormat.createWriter(output, formatVersion)) {
+                forEachIndex(
+                        (column, type, indexWriter) ->
+                                writer.writeIndex(
+                                        column,
+                                        type,
+                                        indexWriter == null ? null : indexWriter::writeTo));
+                writer.finish();
+            }
+            if (output.spilled()) {
+                resultFileName = path.getName();
+            } else {
+                embeddedIndexBytes = output.embeddedBytes();
             }
-            resultFileName = path.getName();
-        } else {
-            embeddedIndexBytes = out.toByteArray();
+        } catch (IOException | RuntimeException e) {
+            try {
+                output.abort();
+            } catch (IOException cleanupFailure) {
+                e.addSuppressed(cleanupFailure);
+            }
+            throw e;
         }
     }
 
-    public Map<String, Map<String, byte[]>> serializeMaintainers() {
-        Map<String, Map<String, byte[]>> indexMaps = new HashMap<>();
-        for (Map<String, IndexMaintainer> columnIndexMaintainers : indexMaintainers.values()) {
-            for (IndexMaintainer indexMaintainer : columnIndexMaintainers.values()) {
-                Map<String, byte[]> mapBytes = indexMaintainer.serializedBytes();
-                for (Map.Entry<String, byte[]> entry : mapBytes.entrySet()) {
-                    indexMaps
-                            .computeIfAbsent(entry.getKey(), k -> new HashMap<>())
-                            .put(indexMaintainer.getIndexType(), entry.getValue());
-                }
+    /** Visits the index writers without serializing their payloads. */
+    public void forEachIndex(IndexConsumer consumer) throws IOException {
+        Map<String, Map<String, FileIndexWriter>> entries = new HashMap<>();
+        for (Map<String, IndexMaintainer> columnMaintainers : indexMaintainers.values()) {
+            for (IndexMaintainer maintainer : columnMaintainers.values()) {
+                maintainer.forEachIndex(
+                        (column, type, writer) ->
+                                entries.computeIfAbsent(column, ignored -> new HashMap<>())
+                                        .put(type, writer));
+            }
+        }
+        for (Map.Entry<String, Map<String, FileIndexWriter>> column : entries.entrySet()) {
+            for (Map.Entry<String, FileIndexWriter> index : column.getValue().entrySet()) {
+                consumer.accept(column.getKey(), index.getKey(), index.getValue());
             }
         }
-        return indexMaps;
+    }
+
+    /** Consumes the writer for one column and index type. */
+    @FunctionalInterface
+    public interface IndexConsumer {
+        void accept(String columnName, String indexType, @Nullable FileIndexWriter writer)
+                throws IOException;
     }
 
     public FileIndexResult result() {
@@ -241,9 +261,7 @@ public final class DataFileIndexWriter implements Closeable {
 
         void write(InternalRow row);
 
-        String getIndexType();
-
-        Map<String, byte[]> serializedBytes();
+        void forEachIndex(IndexConsumer consumer) throws IOException;
     }
 
     /** One index maintainer for one column. */
@@ -269,12 +287,8 @@ public final class DataFileIndexWriter implements Closeable {
             fileIndexWriter.writeRecord(getter.getFieldOrNull(row));
         }
 
-        public String getIndexType() {
-            return indexType;
-        }
-
-        public Map<String, byte[]> serializedBytes() {
-            return Collections.singletonMap(columnName, fileIndexWriter.serializedBytes());
+        public void forEachIndex(IndexConsumer consumer) throws IOException {
+            consumer.accept(columnName, indexType, fileIndexWriter);
         }
     }
 
@@ -348,22 +362,14 @@ public final class DataFileIndexWriter implements Closeable {
                             .createWriter());
         }
 
-        public String getIndexType() {
-            return indexType;
-        }
-
-        public Map<String, byte[]> serializedBytes() {
-            Map<String, byte[]> result = new HashMap<>();
-            indexWritersMap.forEach(
-                    (k, v) -> {
-                        if (!v.empty()) {
-                            result.put(
-                                    FileIndexCommon.toMapKey(columnName, k), v.serializedBytes());
-                        } else {
-                            result.put(FileIndexCommon.toMapKey(columnName, k), null);
-                        }
-                    });
-            return result;
+        public void forEachIndex(IndexConsumer consumer) throws IOException {
+            for (Map.Entry<String, FileIndexWriter> entry : indexWritersMap.entrySet()) {
+                FileIndexWriter writer = entry.getValue();
+                consumer.accept(
+                        FileIndexCommon.toMapKey(columnName, entry.getKey()),
+                        indexType,
+                        writer.empty() ? null : writer);
+            }
         }
     }
 }
diff --git a/paimon-core/src/main/java/org/apache/paimon/io/SpillableIndexOutputStream.java b/paimon-core/src/main/java/org/apache/paimon/io/SpillableIndexOutputStream.java
new file mode 100644
index 0000000000..72a7b19231
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/io/SpillableIndexOutputStream.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.io;
+
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/** Keeps a small file index in memory and switches to an independent file at the threshold. */
+public final class SpillableIndexOutputStream extends OutputStream {
+
+    private final FileIO fileIO;
+    private final Path path;
+    private final int threshold;
+    private ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+    private OutputStream fileOutput;
+    private boolean fileCreated;
+    private boolean closed;
+
+    public SpillableIndexOutputStream(FileIO fileIO, Path path, int threshold) {
+        this.fileIO = fileIO;
+        this.path = path;
+        this.threshold = threshold;
+    }
+
+    @Override
+    public void write(int value) throws IOException {
+        if (fileOutput == null && buffer.size() >= threshold) {
+            spill();
+        }
+        if (fileOutput == null) {
+            buffer.write(value);
+        } else {
+            fileOutput.write(value);
+        }
+    }
+
+    @Override
+    public void write(byte[] bytes, int offset, int length) throws IOException {
+        if (fileOutput == null && (long) buffer.size() + length > threshold) {
+            spill();
+        }
+        if (fileOutput == null) {
+            buffer.write(bytes, offset, length);
+        } else {
+            fileOutput.write(bytes, offset, length);
+        }
+    }
+
+    private void spill() throws IOException {
+        fileOutput = fileIO.newOutputStream(path, true);
+        fileCreated = true;
+        buffer.writeTo(fileOutput);
+        buffer = new ByteArrayOutputStream();
+    }
+
+    public boolean spilled() {
+        return fileCreated;
+    }
+
+    public byte[] embeddedBytes() {
+        return buffer.toByteArray();
+    }
+
+    @Override
+    public void flush() throws IOException {
+        if (fileOutput != null) {
+            fileOutput.flush();
+        }
+    }
+
+    @Override
+    public void close() throws IOException {
+        if (!closed) {
+            closed = true;
+            if (fileOutput != null) {
+                fileOutput.close();
+            }
+        }
+    }
+
+    /** Removes an unpublished independent file after a failed write. */
+    public void abort() throws IOException {
+        try {
+            close();
+        } finally {
+            if (fileCreated) {
+                if (!fileIO.delete(path, false) && fileIO.exists(path)) {
+                    throw new IOException("Failed to delete partial file index file " + path);
+                }
+            }
+        }
+    }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/system/FileIndexesTable.java b/paimon-core/src/main/java/org/apache/paimon/table/system/FileIndexesTable.java
index b13ccd7bb9..a56b014211 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/system/FileIndexesTable.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/system/FileIndexesTable.java
@@ -327,7 +327,7 @@ public class FileIndexesTable implements ReadonlyTable {
             if (embeddedIndex != null) {
                 try (FileIndexFormat.Reader reader =
                         FileIndexFormat.createMetadataReader(
-                                new ByteArraySeekableStream(embeddedIndex))) {
+                                new ByteArraySeekableStream(embeddedIndex), embeddedIndex.length)) {
                     return toRows(
                             dataSplit,
                             file,
@@ -365,7 +365,7 @@ public class FileIndexesTable implements ReadonlyTable {
                 long containerSize = storeTable.fileIO().getFileStatus(indexPath).getLen();
                 try (FileIndexFormat.Reader reader =
                         FileIndexFormat.createMetadataReader(
-                                storeTable.fileIO().newInputStream(indexPath))) {
+                                storeTable.fileIO().newInputStream(indexPath), containerSize)) {
                     return toRows(
                             dataSplit,
                             file,
@@ -420,7 +420,7 @@ public class FileIndexesTable implements ReadonlyTable {
                                 BinaryString.fromString(indexMeta.indexType()),
                                 BinaryString.fromString(storageType),
                                 indexFilePath,
-                                (long) indexMeta.sizeInBytes(),
+                                indexMeta.sizeInBytesLong(),
                                 containerSize,
                                 indexMeta.empty()));
             }
diff --git a/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java b/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java
index 585dd718dd..513331f755 100644
--- a/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java
@@ -353,6 +353,24 @@ public class CoreOptionsTest {
                 .hasMessageContaining("local-kv-db.block-size");
     }
 
+    @Test
+    public void testFileIndexInManifestThreshold() {
+        Options conf = new Options();
+        conf.set(CoreOptions.FILE_INDEX_IN_MANIFEST_THRESHOLD, MemorySize.parse("0 bytes"));
+        assertThat(new CoreOptions(conf).fileIndexInManifestThreshold()).isZero();
+
+        conf.set(
+                CoreOptions.FILE_INDEX_IN_MANIFEST_THRESHOLD, MemorySize.parse("2147483647 bytes"));
+        assertThat(new CoreOptions(conf).fileIndexInManifestThreshold())
+                .isEqualTo((long) Integer.MAX_VALUE);
+
+        conf.set(
+                CoreOptions.FILE_INDEX_IN_MANIFEST_THRESHOLD, MemorySize.parse("2147483648 bytes"));
+        assertThatThrownBy(() -> new CoreOptions(conf).fileIndexInManifestThreshold())
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining(CoreOptions.FILE_INDEX_IN_MANIFEST_THRESHOLD.key());
+    }
+
     @Test
     public void testFormatTableCommitCleanupThreadNumDefaultsTo64AndAcceptsBounds() {
         Options conf = new Options();
diff --git a/paimon-core/src/test/java/org/apache/paimon/append/DedicatedFormatRollingFileWriterVectorTest.java b/paimon-core/src/test/java/org/apache/paimon/append/DedicatedFormatRollingFileWriterVectorTest.java
index 9f1234a4da..cfdc7cae75 100644
--- a/paimon-core/src/test/java/org/apache/paimon/append/DedicatedFormatRollingFileWriterVectorTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/append/DedicatedFormatRollingFileWriterVectorTest.java
@@ -333,7 +333,9 @@ public class DedicatedFormatRollingFileWriterVectorTest {
         PredicateBuilder predicateBuilder = new PredicateBuilder(normalFileSchema);
         try (FileIndexPredicate index =
                 new FileIndexPredicate(
-                        new ByteArraySeekableStream(mainFile.embeddedIndex()), normalFileSchema)) {
+                        new ByteArraySeekableStream(mainFile.embeddedIndex()),
+                        normalFileSchema,
+                        mainFile.embeddedIndex().length)) {
             assertThat(((BitmapIndexResult) index.evaluate(predicateBuilder.equal(0, 2))).get())
                     .isEqualTo(RoaringBitmap32.bitmapOf(2));
             assertThat(index.evaluate(predicateBuilder.equal(0, 99)).remain()).isFalse();
diff --git a/paimon-core/src/test/java/org/apache/paimon/index/FileIndexProcessorTest.java b/paimon-core/src/test/java/org/apache/paimon/index/FileIndexProcessorTest.java
index bfdf0f45b2..2dbc54cea8 100644
--- a/paimon-core/src/test/java/org/apache/paimon/index/FileIndexProcessorTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/index/FileIndexProcessorTest.java
@@ -31,6 +31,7 @@ import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.local.LocalFileIO;
 import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.io.DataFilePathFactory;
+import org.apache.paimon.io.SparseFileIndexIO;
 import org.apache.paimon.manifest.ManifestEntry;
 import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.schema.Schema;
@@ -44,6 +45,8 @@ import org.apache.paimon.types.RowType;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.util.Collections;
 import java.util.HashMap;
@@ -60,12 +63,74 @@ public class FileIndexProcessorTest {
     @TempDir java.nio.file.Path tempDir;
 
     @Test
-    public void testProcessIndexesTwoKeysOfOneMapColumn() throws Exception {
+    public void testV2RewritesContainerOverTwoGiB() throws Exception {
+        SparseFileIndexIO fileIO = new SparseFileIndexIO();
+        Path warehouse = new Path(tempDir.toUri());
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "1");
+        options.put(CoreOptions.FILE_FORMAT.key(), "parquet");
+        options.put(CoreOptions.FILE_INDEX_FORMAT_VERSION.key(), "2");
+        options.put("file-index.in-manifest-threshold", "1B");
+        options.put("file-index.stream-test.columns", "large,small");
+        options.put("file-index.stream-test.large.large", "true");
+        RowType rowType =
+                RowType.builder()
+                        .field("large", DataTypes.INT())
+                        .field("small", DataTypes.INT())
+                        .build();
+        Identifier identifier = Identifier.create("mydb", "t");
+        FileStoreTable table;
+        try (FileSystemCatalog catalog = new FileSystemCatalog(fileIO, warehouse)) {
+            catalog.createDatabase("mydb", false);
+            catalog.createTable(
+                    identifier,
+                    new Schema(
+                            rowType.getFields(),
+                            Collections.emptyList(),
+                            Collections.singletonList("large"),
+                            options,
+                            ""),
+                    false);
+            table = (FileStoreTable) catalog.getTable(identifier);
+        }
+
+        String commitUser = UUID.randomUUID().toString();
+        try (TableWriteImpl<?> write = table.newWrite(commitUser);
+                TableCommitImpl commit = table.newCommit(commitUser)) {
+            write.write(GenericRow.of(1, 2));
+            commit.commit(1, write.prepareCommit(false, 1));
+        }
+
+        ManifestEntry entry = table.store().newScan().plan().files().get(0);
+        DataFileMeta rewritten =
+                new FileIndexProcessor(table).process(entry.partition(), entry.bucket(), entry);
+        String indexFile =
+                rewritten.extraFiles().stream()
+                        .filter(name -> name.endsWith(DataFilePathFactory.INDEX_PATH_SUFFIX))
+                        .findFirst()
+                        .orElseThrow(() -> new AssertionError("no rewritten file index"));
+        Path indexPath =
+                new Path(
+                        table.store().pathFactory().bucketPath(entry.partition(), entry.bucket()),
+                        indexFile);
+        long length = fileIO.getFileStatus(indexPath).getLen();
+        assertThat(length).isGreaterThan(Integer.MAX_VALUE);
+        try (FileIndexFormat.Reader reader =
+                FileIndexFormat.createReader(fileIO.newInputStream(indexPath), rowType, length)) {
+            assertThat(reader.readColumnIndex("large")).hasSize(1);
+            assertThat(reader.readColumnIndex("small")).hasSize(1);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {1, 2})
+    public void testProcessIndexesTwoKeysOfOneMapColumn(int version) throws Exception {
         LocalFileIO fileIO = LocalFileIO.create();
         Path warehouse = new Path(tempDir.toString());
         Map<String, String> options = new HashMap<>();
         options.put(CoreOptions.BUCKET.key(), "1");
         options.put(CoreOptions.FILE_FORMAT.key(), "parquet");
+        options.put(CoreOptions.FILE_INDEX_FORMAT_VERSION.key(), Integer.toString(version));
         // both entries share the top level column "m"
         options.put(CoreOptions.FILE_INDEX + ".bloom-filter.columns", "m[k1],m[k2]");
         RowType rowType =
@@ -122,8 +187,13 @@ public class FileIndexProcessorTest {
                         table.store().pathFactory().bucketPath(entry.partition(), entry.bucket()),
                         indexFile);
         try (FileIndexFormat.Reader reader =
-                FileIndexFormat.createReader(fileIO.newInputStream(indexPath), rowType)) {
-            assertThat(reader.readAll().keySet()).containsExactlyInAnyOrder("m[k1]", "m[k2]");
+                FileIndexFormat.createReader(
+                        fileIO.newInputStream(indexPath),
+                        rowType,
+                        fileIO.getFileStatus(indexPath).getLen())) {
+            assertThat(reader.indexMetas())
+                    .extracting(FileIndexFormat.FileIndexMeta::columnName)
+                    .containsExactlyInAnyOrder("m[k1]", "m[k2]");
         }
     }
 
@@ -239,7 +309,8 @@ public class FileIndexProcessorTest {
             // file), so the index was rebuilt over nulls and 100 would be absent.
             FieldRef vRef = new FieldRef(0, "v", DataTypes.INT());
             try (FileIndexFormat.Reader reader =
-                    FileIndexFormat.createReader(new ByteArraySeekableStream(embedded), rowType)) {
+                    FileIndexFormat.createReader(
+                            new ByteArraySeekableStream(embedded), rowType, embedded.length)) {
                 Set<FileIndexReader> vReaders = reader.readColumnIndex("v");
                 assertThat(vReaders).isNotEmpty();
                 for (FileIndexReader vReader : vReaders) {
diff --git a/paimon-core/src/test/java/org/apache/paimon/io/DataFileIndexWriterTest.java b/paimon-core/src/test/java/org/apache/paimon/io/DataFileIndexWriterTest.java
index df892f3447..c5c917d4dc 100644
--- a/paimon-core/src/test/java/org/apache/paimon/io/DataFileIndexWriterTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/io/DataFileIndexWriterTest.java
@@ -28,6 +28,7 @@ import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.disk.IOManager;
 import org.apache.paimon.disk.IOManagerImpl;
 import org.apache.paimon.fileindex.FileIndexFormat;
+import org.apache.paimon.fileindex.FileIndexOptions;
 import org.apache.paimon.fileindex.FileIndexReader;
 import org.apache.paimon.fileindex.bitmap.BitmapIndexResult;
 import org.apache.paimon.fs.FileIO;
@@ -54,7 +55,10 @@ import org.apache.paimon.utils.RoaringBitmap32;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
+import java.io.IOException;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -63,6 +67,9 @@ import java.util.function.Consumer;
 import java.util.stream.Collectors;
 
 import static org.apache.paimon.options.CatalogOptions.CACHE_ENABLED;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.catchThrowable;
 
 /** Tests for {@link DataFileIndexWriter}. */
 public class DataFileIndexWriterTest {
@@ -76,7 +83,127 @@ public class DataFileIndexWriterTest {
     boolean bloomExists = false;
 
     @Test
-    public void testCreatingMultipleIndexesOnOneColumn() throws Exception {
+    public void testSpillableIndexOutputStream() throws Exception {
+        Path path = new Path(tempFile.resolve("index.idx").toUri());
+        SpillableIndexOutputStream embedded = new SpillableIndexOutputStream(fileIO, path, 4);
+        embedded.write(new byte[] {1, 2, 3, 4});
+        embedded.close();
+        assertThat(embedded.spilled()).isFalse();
+        assertThat(embedded.embeddedBytes()).containsExactly(1, 2, 3, 4);
+
+        SpillableIndexOutputStream external = new SpillableIndexOutputStream(fileIO, path, 4);
+        external.write(new byte[] {1, 2, 3, 4});
+        external.write(5);
+        external.close();
+        assertThat(external.spilled()).isTrue();
+        try (org.apache.paimon.fs.SeekableInputStream input = fileIO.newInputStream(path)) {
+            byte[] bytes = new byte[5];
+            input.read(bytes);
+            assertThat(bytes).containsExactly(1, 2, 3, 4, 5);
+        }
+        external.abort();
+        assertThat(fileIO.exists(path)).isFalse();
+    }
+
+    @Test
+    public void testV2TableWriterStreamsPayloadOverTwoGiB() throws Exception {
+        Options options = new Options();
+        options.setString("file-index.format.version", "2");
+        options.setString("file-index.in-manifest-threshold", "1B");
+        options.setString("file-index.stream-test.columns", "large,small");
+        options.setString("file-index.stream-test.large.large", "true");
+        Path path = new Path(tempFile.resolve("large.index").toUri());
+        FileIO sparseFileIO = new SparseFileIndexIO();
+        RowType rowType =
+                RowType.builder()
+                        .field("large", DataTypes.INT())
+                        .field("small", DataTypes.INT())
+                        .build();
+        DataFileIndexWriter writer =
+                new DataFileIndexWriter(
+                        sparseFileIO,
+                        path,
+                        rowType,
+                        new FileIndexOptions(new CoreOptions(options)),
+                        null);
+        writer.write(GenericRow.of(1, 2));
+        writer.close();
+        assertThat(writer.result().independentIndexFile()).isEqualTo(path.getName());
+
+        long length = sparseFileIO.getFileStatus(path).getLen();
+        assertThat(length).isGreaterThan(Integer.MAX_VALUE);
+        try (FileIndexFormat.Reader reader =
+                FileIndexFormat.createReader(sparseFileIO.newInputStream(path), rowType, length)) {
+            assertThat(reader.indexMetas())
+                    .filteredOn(meta -> meta.columnName().equals("large"))
+                    .extracting(FileIndexFormat.FileIndexMeta::sizeInBytesLong)
+                    .containsExactly(2049L * 1024 * 1024 + 1);
+            assertThat(reader.readColumnIndex("large")).hasSize(1);
+            assertThat(reader.readColumnIndex("small")).hasSize(1);
+        }
+    }
+
+    @Test
+    public void testV2FailedWriteDeletesPartialIndexFile() throws Exception {
+        Options options = new Options();
+        options.setString("file-index.format.version", "2");
+        options.setString("file-index.in-manifest-threshold", "1B");
+        options.setString("file-index.stream-test.columns", "a");
+        options.setString("file-index.stream-test.a.fail", "true");
+        Path path = new Path(tempFile.resolve("failed.index").toUri());
+        DataFileIndexWriter writer =
+                new DataFileIndexWriter(
+                        fileIO,
+                        path,
+                        RowType.builder().field("a", DataTypes.INT()).build(),
+                        new FileIndexOptions(new CoreOptions(options)),
+                        null);
+        writer.write(GenericRow.of(1));
+        assertThatThrownBy(writer::close)
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Test index write failure");
+        assertThat(fileIO.exists(path)).isFalse();
+        assertThat(writer.result().independentIndexFile()).isNull();
+    }
+
+    @Test
+    public void testV2FailedWriteReportsDeleteFailure() throws Exception {
+        Options options = new Options();
+        options.setString("file-index.format.version", "2");
+        options.setString("file-index.in-manifest-threshold", "1B");
+        options.setString("file-index.stream-test.columns", "a");
+        options.setString("file-index.stream-test.a.fail", "true");
+        Path path = new Path(tempFile.resolve("undeleted.index").toUri());
+        FileIO deleteFailingFileIO =
+                new LocalFileIO() {
+                    @Override
+                    public boolean delete(Path path, boolean recursive) {
+                        return false;
+                    }
+                };
+        DataFileIndexWriter writer =
+                new DataFileIndexWriter(
+                        deleteFailingFileIO,
+                        path,
+                        RowType.builder().field("a", DataTypes.INT()).build(),
+                        new FileIndexOptions(new CoreOptions(options)),
+                        null);
+        writer.write(GenericRow.of(1));
+
+        Throwable failure = catchThrowable(writer::close);
+        assertThat(failure)
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Test index write failure");
+        assertThat(failure.getSuppressed()).hasSize(1);
+        assertThat(failure.getSuppressed()[0])
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Failed to delete partial file index file");
+        assertThat(deleteFailingFileIO.exists(path)).isTrue();
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {1, 2})
+    public void testCreatingMultipleIndexesOnOneColumn(int version) throws Exception {
 
         String tableName = "test";
         String col1 = "f0";
@@ -84,6 +211,7 @@ public class DataFileIndexWriterTest {
         Identifier identifier = Identifier.create(tableName, tableName);
 
         Map<String, String> optionsMap = new HashMap<>();
+        optionsMap.put("file-index.format.version", Integer.toString(version));
         optionsMap.put("file-index.bitmap.columns", col1);
         optionsMap.put("file-index.bsi.columns", col1);
         optionsMap.put("file-index.bloom-filter.columns", col2);
@@ -197,12 +325,12 @@ public class DataFileIndexWriterTest {
                                 .collect(Collectors.toList());
                 // assert index file exist and only one index file
                 assert indexFiles.size() == 1;
+                Path indexPath = dataFilePathFactory.toAlignedPath(indexFiles.get(0), dataFileMeta);
                 try (FileIndexFormat.Reader reader =
                         FileIndexFormat.createReader(
-                                fileIO.newInputStream(
-                                        dataFilePathFactory.toAlignedPath(
-                                                indexFiles.get(0), dataFileMeta)),
-                                tableSchema.logicalRowType())) {
+                                fileIO.newInputStream(indexPath),
+                                tableSchema.logicalRowType(),
+                                fileIO.getFileStatus(indexPath).getLen())) {
                     Set<FileIndexReader> fileIndexReaders = reader.readColumnIndex(columnName);
                     for (FileIndexReader fileIndexReader : fileIndexReaders) {
                         consumer.accept(fileIndexReader);
diff --git a/paimon-core/src/test/java/org/apache/paimon/io/FileIndexEvaluatorTest.java b/paimon-core/src/test/java/org/apache/paimon/io/FileIndexEvaluatorTest.java
index bd7f781855..5c1c4c79da 100644
--- a/paimon-core/src/test/java/org/apache/paimon/io/FileIndexEvaluatorTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/io/FileIndexEvaluatorTest.java
@@ -41,8 +41,6 @@ import org.junit.jupiter.api.Test;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -203,15 +201,10 @@ public class FileIndexEvaluatorTest {
         indexWriter.write(1);
         indexWriter.write(2);
 
-        Map<String, Map<String, byte[]>> indexes = new HashMap<>();
-        indexes.put(
-                "a",
-                Collections.singletonMap(
-                        BitmapFileIndexFactory.BITMAP_INDEX, indexWriter.serializedBytes()));
-
         ByteArrayOutputStream output = new ByteArrayOutputStream();
-        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(output)) {
-            writer.writeColumnIndexes(indexes);
+        try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(output, 1)) {
+            writer.writeIndex("a", BitmapFileIndexFactory.BITMAP_INDEX, indexWriter::writeTo);
+            writer.finish();
         }
         return output.toByteArray();
     }
diff --git a/paimon-core/src/test/java/org/apache/paimon/io/SparseFileIndexIO.java b/paimon-core/src/test/java/org/apache/paimon/io/SparseFileIndexIO.java
new file mode 100644
index 0000000000..7d5fd354e1
--- /dev/null
+++ b/paimon-core/src/test/java/org/apache/paimon/io/SparseFileIndexIO.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.io;
+
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+
+/** Uses sparse local files for large zero-filled test index payloads. */
+public class SparseFileIndexIO extends LocalFileIO {
+
+    @Override
+    public PositionOutputStream newOutputStream(Path path, boolean overwrite) throws IOException {
+        if (!path.getName().endsWith(DataFilePathFactory.INDEX_PATH_SUFFIX)) {
+            return super.newOutputStream(path, overwrite);
+        }
+        File file = new File(path.toUri());
+        file.getParentFile().mkdirs();
+        RandomAccessFile output = new RandomAccessFile(file, "rw");
+        output.setLength(0);
+        return new PositionOutputStream() {
+            private long position;
+
+            @Override
+            public long getPos() {
+                return position;
+            }
+
+            @Override
+            public void write(int value) throws IOException {
+                output.write(value);
+                position++;
+            }
+
+            @Override
+            public void write(byte[] bytes) throws IOException {
+                write(bytes, 0, bytes.length);
+            }
+
+            @Override
+            public void write(byte[] bytes, int offset, int length) throws IOException {
+                boolean zeros = length >= 8192;
+                for (int i = offset; zeros && i < offset + length; i++) {
+                    zeros = bytes[i] == 0;
+                }
+                if (zeros) {
+                    output.seek(position + length);
+                } else {
+                    output.write(bytes, offset, length);
+                }
+                position += length;
+            }
+
+            @Override
+            public void flush() {}
+
+            @Override
+            public void close() throws IOException {
+                output.setLength(position);
+                output.close();
+            }
+        };
+    }
+}
diff --git a/paimon-core/src/test/java/org/apache/paimon/io/StreamingTestFileIndexFactory.java b/paimon-core/src/test/java/org/apache/paimon/io/StreamingTestFileIndexFactory.java
new file mode 100644
index 0000000000..fb9fd146c6
--- /dev/null
+++ b/paimon-core/src/test/java/org/apache/paimon/io/StreamingTestFileIndexFactory.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.io;
+
+import org.apache.paimon.fileindex.FileIndexReader;
+import org.apache.paimon.fileindex.FileIndexWriter;
+import org.apache.paimon.fileindex.FileIndexer;
+import org.apache.paimon.fileindex.FileIndexerFactory;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataType;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/** Test-only index whose streaming payload can exceed the byte-array limit. */
+public class StreamingTestFileIndexFactory implements FileIndexerFactory {
+
+    private static final byte MARKER = 7;
+
+    @Override
+    public String identifier() {
+        return "stream-test";
+    }
+
+    @Override
+    public FileIndexer create(DataType type, Options options) {
+        boolean large = options.getBoolean("large", false);
+        boolean fail = options.getBoolean("fail", false);
+        return new FileIndexer() {
+            @Override
+            public FileIndexWriter createWriter() {
+                return new FileIndexWriter() {
+                    @Override
+                    public void write(Object key) {}
+
+                    @Override
+                    public void writeTo(OutputStream output) throws IOException {
+                        if (large) {
+                            byte[] block = new byte[1024 * 1024];
+                            for (int i = 0; i < 2049; i++) {
+                                output.write(block);
+                            }
+                        }
+                        output.write(MARKER);
+                        if (fail) {
+                            throw new IOException("Test index write failure");
+                        }
+                    }
+                };
+            }
+
+            @Override
+            public FileIndexReader createReader(
+                    SeekableInputStream inputStream, int start, int length) {
+                return createReader(inputStream, (long) start, (long) length);
+            }
+
+            @Override
+            public FileIndexReader createReader(
+                    SeekableInputStream inputStream, long start, long length) {
+                try {
+                    inputStream.seek(start + length - 1);
+                    if (inputStream.read() != MARKER) {
+                        throw new IOException("Missing streaming index marker");
+                    }
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+                return new FileIndexReader() {};
+            }
+        };
+    }
+}
diff --git a/paimon-core/src/test/java/org/apache/paimon/table/system/FileIndexesTableTest.java b/paimon-core/src/test/java/org/apache/paimon/table/system/FileIndexesTableTest.java
index 133d07238c..5ff507a1b5 100644
--- a/paimon-core/src/test/java/org/apache/paimon/table/system/FileIndexesTableTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/table/system/FileIndexesTableTest.java
@@ -39,6 +39,8 @@ import org.apache.paimon.table.source.Split;
 import org.apache.paimon.types.DataTypes;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.UncheckedIOException;
 import java.util.ArrayList;
@@ -52,9 +54,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
 /** Tests for {@link FileIndexesTable}. */
 public class FileIndexesTableTest extends TableTestBase {
 
-    @Test
-    public void testEmbeddedFileIndexes() throws Exception {
-        FileIndexesTable fileIndexesTable = createTable("EmbeddedIndexes", "1 MB");
+    @ParameterizedTest
+    @ValueSource(ints = {1, 2})
+    public void testEmbeddedFileIndexes(int version) throws Exception {
+        FileIndexesTable fileIndexesTable = createTable("EmbeddedIndexes", "1 MB", version);
 
         List<InternalRow> rows = read(fileIndexesTable);
         assertIndexRows(rows, "EMBEDDED");
@@ -67,9 +70,10 @@ public class FileIndexesTableTest extends TableTestBase {
                                         && !row.getBoolean(12));
     }
 
-    @Test
-    public void testExternalFileIndexes() throws Exception {
-        FileIndexesTable fileIndexesTable = createTable("ExternalIndexes", "1 B");
+    @ParameterizedTest
+    @ValueSource(ints = {1, 2})
+    public void testExternalFileIndexes(int version) throws Exception {
+        FileIndexesTable fileIndexesTable = createTable("ExternalIndexes", "1 B", version);
 
         List<InternalRow> rows = read(fileIndexesTable);
         assertIndexRows(rows, "FILE");
@@ -213,9 +217,20 @@ public class FileIndexesTableTest extends TableTestBase {
         return createTable(tableName, inManifestThreshold, false);
     }
 
+    private FileIndexesTable createTable(String tableName, String inManifestThreshold, int version)
+            throws Exception {
+        return createTable(tableName, inManifestThreshold, false, version);
+    }
+
     private FileIndexesTable createTable(
             String tableName, String inManifestThreshold, boolean writeSeparateFiles)
             throws Exception {
+        return createTable(tableName, inManifestThreshold, writeSeparateFiles, 1);
+    }
+
+    private FileIndexesTable createTable(
+            String tableName, String inManifestThreshold, boolean writeSeparateFiles, int version)
+            throws Exception {
         Identifier identifier = identifier(tableName);
         catalog.createTable(
                 identifier,
@@ -225,6 +240,9 @@ public class FileIndexesTableTest extends TableTestBase {
                         .partitionKeys("pt")
                         .option(CoreOptions.BUCKET.key(), "1")
                         .option(CoreOptions.BUCKET_KEY.key(), "id")
+                        .option(
+                                CoreOptions.FILE_INDEX_FORMAT_VERSION.key(),
+                                Integer.toString(version))
                         .option("file-index.bitmap.columns", "id")
                         .option("file-index.bloom-filter.columns", "id")
                         .option(
diff --git a/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.fileindex.FileIndexerFactory b/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.fileindex.FileIndexerFactory
new file mode 100644
index 0000000000..7c5ada7a43
--- /dev/null
+++ b/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.fileindex.FileIndexerFactory
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+org.apache.paimon.io.StreamingTestFileIndexFactory
diff --git a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RewriteFileIndexActionITCase.java b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RewriteFileIndexActionITCase.java
index fbfd56b000..ad9dd7783e 100644
--- a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RewriteFileIndexActionITCase.java
+++ b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/RewriteFileIndexActionITCase.java
@@ -194,7 +194,9 @@ public class RewriteFileIndexActionITCase extends ActionITCaseBase {
                             .toAlignedPath(file, entry.file());
             try (FileIndexFormat.Reader reader =
                     FileIndexFormat.createReader(
-                            table.fileIO().newInputStream(indexFilePath), table.rowType())) {
+                            table.fileIO().newInputStream(indexFilePath),
+                            table.rowType(),
+                            table.fileIO().getFileStatus(indexFilePath).getLen())) {
                 Set<FileIndexReader> readerSetK = reader.readColumnIndex("k");
                 Assertions.assertThat(readerSetK.size()).isEqualTo(1);
 
diff --git a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/RewriteFileIndexProcedureITCase.java b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/RewriteFileIndexProcedureITCase.java
index ad83304d16..33ca046d20 100644
--- a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/RewriteFileIndexProcedureITCase.java
+++ b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/RewriteFileIndexProcedureITCase.java
@@ -39,7 +39,6 @@ import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
@@ -242,7 +241,9 @@ public class RewriteFileIndexProcedureITCase extends CatalogITCaseBase {
                         .noneMatch(s -> s.endsWith(DataFilePathFactory.INDEX_PATH_SUFFIX));
                 reader =
                         FileIndexFormat.createReader(
-                                new ByteArraySeekableStream(embeddedIndex), table.rowType());
+                                new ByteArraySeekableStream(embeddedIndex),
+                                table.rowType(),
+                                embeddedIndex.length);
             } else {
                 Assertions.assertThat(embeddedIndex).isNull();
                 String indexFile =
@@ -261,12 +262,15 @@ public class RewriteFileIndexProcedureITCase extends CatalogITCaseBase {
                                 .toAlignedPath(indexFile, entry.file());
                 reader =
                         FileIndexFormat.createReader(
-                                table.fileIO().newInputStream(indexFilePath), table.rowType());
+                                table.fileIO().newInputStream(indexFilePath),
+                                table.rowType(),
+                                table.fileIO().getFileStatus(indexFilePath).getLen());
             }
             try (FileIndexFormat.Reader indexReader = reader) {
-                Map<String, Map<String, byte[]>> indexes = indexReader.readAll();
-                Assertions.assertThat(indexes).containsKey("k");
-                Assertions.assertThat(indexes.get("k").keySet()).containsExactly(expectedIndexType);
+                Assertions.assertThat(indexReader.indexMetas())
+                        .filteredOn(meta -> meta.columnName().equals("k"))
+                        .extracting(FileIndexFormat.FileIndexMeta::indexType)
+                        .containsExactly(expectedIndexType);
             }
         }
     }
@@ -329,7 +333,9 @@ public class RewriteFileIndexProcedureITCase extends CatalogITCaseBase {
                             .toAlignedPath(file, entry.file());
             try (FileIndexFormat.Reader reader =
                     FileIndexFormat.createReader(
-                            table.fileIO().newInputStream(indexFilePath), table.rowType())) {
+                            table.fileIO().newInputStream(indexFilePath),
+                            table.rowType(),
+                            table.fileIO().getFileStatus(indexFilePath).getLen())) {
                 Set<FileIndexReader> readerSetK = reader.readColumnIndex("v");
 
                 Assertions.assertThat(readerSetK.size()).isEqualTo(0);
diff --git a/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkFileIndexITCase.java b/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkFileIndexITCase.java
index 9e9d839edd..c0f5c664b9 100644
--- a/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkFileIndexITCase.java
+++ b/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkFileIndexITCase.java
@@ -235,12 +235,12 @@ public class SparkFileIndexITCase extends SparkWriteITCase {
                                 .collect(Collectors.toList());
                 // assert index file exist and only one index file
                 assert indexFiles.size() == 1;
+                Path indexPath = dataFilePathFactory.toAlignedPath(indexFiles.get(0), dataFileMeta);
                 try (FileIndexFormat.Reader reader =
                         FileIndexFormat.createReader(
-                                fileIO.newInputStream(
-                                        dataFilePathFactory.toAlignedPath(
-                                                indexFiles.get(0), dataFileMeta)),
-                                tableSchema.logicalRowType())) {
+                                fileIO.newInputStream(indexPath),
+                                tableSchema.logicalRowType(),
+                                fileIO.getFileStatus(indexPath).getLen())) {
                     Optional<FileIndexReader> fileIndexReader =
                             reader.readColumnIndex("a").stream().findFirst();
                     // assert index reader exist