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 e2127c77d9 [core] Support partition-level file formats for 
catalog-managed Format Tables (#10303)
e2127c77d9 is described below

commit e2127c77d950f9eccf5a77b2d9f82a2683dab7d7
Author: tonymtu <[email protected]>
AuthorDate: Thu Oct 8 16:20:12 2026 +0800

    [core] Support partition-level file formats for catalog-managed Format 
Tables (#10303)
---
 docs/docs/concepts/rest/rest-api.md                |  13 +
 docs/docs/spark/format-table.md                    |  42 ++
 docs/static/rest-catalog-open-api.yaml             |  23 +-
 .../rest/requests/CreatePartitionsRequest.java     |   7 +-
 .../format/CatalogFormatTablePartitionManager.java |   1 +
 .../table/format/CatalogSplitEnumerator.java       |  36 +-
 .../table/format/FormatBatchWriteBuilder.java      |   3 +-
 .../paimon/table/format/FormatDataSplit.java       |  20 +-
 .../paimon/table/format/FormatReadBuilder.java     |  13 +-
 .../paimon/table/format/FormatTableCommit.java     |  92 ++-
 .../table/format/FormatTablePartitionManager.java  |   5 +
 .../table/format/FormatTablePartitionOptions.java  |  61 ++
 .../format/FormatTablePartitionStatsCollector.java |  82 ++-
 .../paimon/table/format/SplitEnumerator.java       |  26 +-
 .../apache/paimon/rest/MockRESTCatalogTest.java    |   6 +-
 .../paimon/rest/RESTCatalogPartitionSupport.java   |  49 ++
 .../org/apache/paimon/rest/RESTCatalogServer.java  |   4 +
 .../table/FormatTableCommitCompatibilityTest.java  | 121 ++++
 .../paimon/table/format/FormatDataSplitTest.java   |  22 +-
 .../paimon/table/format/FormatReadBuilderTest.java |   3 +-
 .../format/FormatTableCommitStatisticsTest.java    |   8 +-
 .../paimon/table/format/FormatTableCommitTest.java |  32 +-
 .../format/FormatTablePartitionFormatTest.java     | 616 +++++++++++++++++++++
 .../paimon/table/format/FormatTableScanTest.java   |   1 +
 .../resources/compatibility/format-data-split-v3   | Bin 0 -> 576 bytes
 paimon-flink/paimon-flink-common/pom.xml           |  16 +
 .../paimon/flink/source/FormatTableITCase.java     |  85 +++
 .../format/mosaic/FormatTableMosaicReadTest.java   |  31 ++
 ...PaimonAnalyzeFormatTablePartitionsCommand.scala |  12 +-
 .../format/FormatTablePartitionRepairTest.java     |  39 +-
 .../paimon/spark/table/PaimonFormatTableTest.scala |  54 ++
 31 files changed, 1427 insertions(+), 96 deletions(-)

diff --git a/docs/docs/concepts/rest/rest-api.md 
b/docs/docs/concepts/rest/rest-api.md
index 18e54faea4..b3279b2652 100644
--- a/docs/docs/concepts/rest/rest-api.md
+++ b/docs/docs/concepts/rest/rest-api.md
@@ -74,6 +74,19 @@ dropped, no data is deleted, and the request needs 
`replaceStatistics=true` with
 invalid, so a server that does not implement this rejects the request rather 
than storing it. A
 server also rejects additive statistics for a partition that already has a 
custom location.
 
+Format Table partitions can also override the table format with `file.format`, 
for example
+`"partitionOptions": [{"file.format": "orc"}]`. An explicit format updates an 
existing partition
+even with `ignoreIfExists=true`, preserving other options. Omitting it 
preserves the stored
+override; a partition without an override inherits the table format, 
defaulting to `parquet`.
+An empty or unsupported format is rejected. The server and all readers and 
writers must support
+partition formats before mixed-format partitions are used.
+
+An additive statistics request that explicitly supplies a different format 
from the existing
+partition's effective format is rejected. Replacement statistics 
(`replaceStatistics=true`) can
+update the format and statistics together. Each request batch is atomic for 
catalog metadata;
+multiple batches and filesystem writes are not one transaction. See the
+[Format Table write and recovery 
boundaries](../../spark/format-table.md#partition-file-formats).
+
 ## Semantic views
 
 See [Semantic Views](semantic-views.md) for experimental definition 
management, complete-document
diff --git a/docs/docs/spark/format-table.md b/docs/docs/spark/format-table.md
index 8422d703f4..70e3ce6aa9 100644
--- a/docs/docs/spark/format-table.md
+++ b/docs/docs/spark/format-table.md
@@ -112,5 +112,47 @@ change the table's managed/external ownership.
 
 ## Writes and Maintenance
 
+### Partition File Formats
+
+Catalog-managed partitions can override the table's `file.format` through the 
partition `options`
+map in the Catalog API or REST `partitionOptions` request. For example, an ORC 
partition of a
+Parquet table can be registered with `{"file.format": "orc"}`. Catalog 
implementations supporting
+this feature must also apply an explicit format update to an existing 
partition, including when
+`ignoreIfExists=true`, while preserving its other options. Omitting the format 
leaves it unchanged.
+
+The effective format is the partition's explicit value, then the table's 
value, then the default
+`parquet`. An absent partition option inherits the table format; an empty or 
invalid value is an
+error. Supported formats match Format Table's table-level formats: ORC, 
Parquet, CSV, TEXT, JSON
+and MOSAIC, with the same schema and dependency requirements. Other format 
settings, such as CSV
+delimiters, come from the table. All files within one partition must use its 
effective format.
+
+The shared Paimon reader uses this metadata in both Spark and Flink. A 
partition can specify both
+`path` and `file.format`. Directory-discovered partitions and Spark's `engine` 
implementation do
+not use these overrides. Updating an option describes the existing files; it 
does not convert them.
+
+Writers continue to use the table's write format. Appending to a partition 
registered with a
+different format fails before files are published. Overwrite replaces the 
targeted partitions
+using the write format and always reports that format with the replacement 
statistics. This stores
+an explicit `file.format` even when the partition previously inherited the 
table format. Appends
+leave the format option unchanged, and TRUNCATE preserves the existing format.
+Changing only the table's default does not convert existing data: register the 
actual format of
+partitions that would otherwise inherit the new default before changing it. 
All readers and writers
+of a mixed table, and its catalog provider, must support partition file 
formats.
+
+Overwrites of the same partition must be externally serialized with other 
writes. Changes to a
+partition's format metadata must also be serialized with both appends and 
overwrites: the append
+check does not prevent the format from changing before files are published. 
Queries running during
+an overwrite have no snapshot isolation and may observe files and metadata 
from different stages
+of the overwrite.
+
+If new files are published but the catalog update fails, an overwrite can 
leave the previous
+format and statistics describing the replacement files. The new files are 
preserved; the old
+files are not restored. With other writes stopped, verify that the replacement 
is complete and
+uses one format, then repair the partition's `file.format` and replace its 
statistics through the
+Catalog API. An incomplete replacement needs a fresh overwrite from the source 
data.
+
+`ANALYZE TABLE` chooses the statistics reader per partition. Formats without 
footer row counts
+leave the row count unknown. The existing restriction on analyzing custom 
locations still applies.
+
 See [SQL Writes](./sql-write#insert-overwrite) for overwrite behavior and
 [TRUNCATE TABLE](./sql-write#truncate-table) for how data, registrations, and 
statistics are updated.
diff --git a/docs/static/rest-catalog-open-api.yaml 
b/docs/static/rest-catalog-open-api.yaml
index 02ee6df595..a52be685d0 100644
--- a/docs/static/rest-catalog-open-api.yaml
+++ b/docs/static/rest-catalog-open-api.yaml
@@ -986,7 +986,8 @@ paths:
       summary: Create partitions
       description: >
         Creates partitions with optional position-aligned options. The server 
validates and stores
-        the partitions, options, and statistics atomically.
+        the partitions, options, and statistics atomically within each request 
batch. Multiple
+        batches and filesystem writes are not one transaction.
       operationId: createPartitions
       parameters:
         - name: prefix
@@ -2776,7 +2777,25 @@ components:
           description: Whether partitionStatistics replace the stored values 
rather than add to them; required whenever partitionStatistics is present, and 
absent otherwise. Replacing overwrites recordCount, fileSizeInBytes, fileCount 
and lastFileCreationTime; adding sums the three counts and keeps the later 
lastFileCreationTime, since two timestamps do not add. A field reported as 
unknown leaves the stored one alone either way, and totalBuckets is never 
combined. A client that reports o [...]
           type: [ boolean, "null" ]
         partitionOptions:
-          description: Optional options aligned with partitionSpecs by 
position. The list must have the same length as partitionSpecs; use an empty 
object when a partition has no options. A custom location is stored under the 
path key. Omitting path keeps the stored location; a path outside the table 
location registers a custom location for a new partition and must equal the 
stored path for an existing one; the partition's own default directory under 
the table location returns the partit [...]
+          description: >-
+            Optional options aligned with partitionSpecs by position. The list 
must have the same
+            length as partitionSpecs; use an empty object when a partition has 
no options.
+            A custom location is stored under the path key. Omitting path 
keeps the stored location;
+            a path outside the table location registers a custom location for 
a new partition and
+            must equal the stored path for an existing one; the partition's 
own default directory
+            under the table location returns the partition there, dropping the 
stored path without
+            deleting data. Returning a partition to its default directory 
requires
+            replaceStatistics=true and a partitionStatistics entry with the 
same spec, applied in
+            the same request. Any other path under the table location is 
invalid, so a provider
+            that does not implement this rejects the request instead of 
storing it.
+            For Format Tables, file.format overrides the table format (default 
parquet). Supported
+            values are orc, parquet, csv, text, json and mosaic, 
case-insensitive; empty or unsupported
+            values are rejected. An explicit format updates existing 
partitions even with
+            ignoreIfExists=true, preserving other options; omitting it 
preserves the stored override.
+            An additive statistics request with an explicit format differing 
from the existing
+            partition's effective format is rejected. Replacement statistics 
allow the format and
+            statistics to change together, atomically within the request 
batch. Mixed-format
+            partitions require compatible servers, readers and writers.
           type: [ array, "null" ]
           items:
             type: object
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/CreatePartitionsRequest.java
 
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/CreatePartitionsRequest.java
index e5260a8dcb..e9aa6eb84f 100644
--- 
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/CreatePartitionsRequest.java
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/CreatePartitionsRequest.java
@@ -42,7 +42,12 @@ import static 
org.apache.paimon.utils.Preconditions.checkArgument;
  * PartitionStatistics#spec()} rather than by position, so they may cover only 
some of them. Both
  * statistics fields are absent unless the client reports. Partition options 
align with {@code
  * partitionSpecs} by position; naming the partition's own default directory 
in {@code path} asks
- * the catalog to put it back there, which needs replacement statistics for 
that partition.
+ * the catalog to put it back there, which needs replacement statistics for 
that partition. An
+ * explicit {@code file.format} sets the partition's format without changing 
other options; omitting
+ * it preserves the stored override. Catalogs supporting partition formats 
apply explicit format
+ * updates even when {@code ignoreIfExists=true}, together with the statistics 
in the same batch.
+ * Additive statistics must describe files in the partition's effective 
format, whereas a
+ * replacement may report a new format.
  */
 @JsonIgnoreProperties(ignoreUnknown = true)
 public class CreatePartitionsRequest implements RESTRequest {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
index 72e23a200c..39f061512a 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
@@ -218,6 +218,7 @@ class CatalogFormatTablePartitionManager implements 
FormatTablePartitionManager
         for (int i = 0; i < partitionOptions.size(); i++) {
             Map<String, String> options = partitionOptions.get(i);
             checkArgument(options != null, "Partition options must not contain 
null maps.");
+            FormatTablePartitionOptions.fileFormatOverride(options);
             checkArgument(
                     options.entrySet().stream()
                             .noneMatch(entry -> entry.getKey() == null || 
entry.getValue() == null),
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogSplitEnumerator.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogSplitEnumerator.java
index a65d224de3..61392cc570 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogSplitEnumerator.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogSplitEnumerator.java
@@ -43,6 +43,7 @@ import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -89,7 +90,8 @@ final class CatalogSplitEnumerator extends SplitEnumerator {
     List<Split> enumeratePartitions(@Nullable PartitionPredicate 
partitionFilter)
             throws IOException {
         CatalogPartitionListing listing = 
findCatalogPartitions(partitionFilter);
-        return enumeratePartitions(filterPartitions(listing.partitionPaths, 
partitionFilter));
+        return enumeratePartitions(
+                filterPartitions(listing.partitionPaths, partitionFilter), 
listing.formats);
     }
 
     @Override
@@ -101,11 +103,13 @@ final class CatalogSplitEnumerator extends 
SplitEnumerator {
         List<Pair<LinkedHashMap<String, String>, Path>> selected =
                 filterPartitions(listing.partitionPaths, partitionFilter);
         List<PartitionEntry> entries = toPartitionEntries(listing.partitions, 
partitionFilter);
-        return new ScanPlan(enumeratePartitions(selected), rowCount(entries));
+        return new ScanPlan(enumeratePartitions(selected, listing.formats), 
rowCount(entries));
     }
 
     private List<Split> enumeratePartitions(
-            List<Pair<LinkedHashMap<String, String>, Path>> partitions) throws 
IOException {
+            List<Pair<LinkedHashMap<String, String>, Path>> partitions,
+            Map<Map<String, String>, String> formats)
+            throws IOException {
         List<Split> splits = new ArrayList<>();
         if (partitions.isEmpty()) {
             return splits;
@@ -140,7 +144,8 @@ final class CatalogSplitEnumerator extends SplitEnumerator {
                                 fileIOResolver.fileIO(useCatalogContextFileIO),
                                 pair.getValue(),
                                 partitionRow,
-                                useCatalogContextFileIO);
+                                useCatalogContextFileIO,
+                                formats.get(pair.getKey()));
                     } catch (FileNotFoundException e) {
                         warnMissingPartition(pair.getKey(), pair.getValue());
                         return Collections.emptyList();
@@ -179,7 +184,23 @@ final class CatalogSplitEnumerator extends SplitEnumerator 
{
         }
         List<Pair<LinkedHashMap<String, String>, Path>> partitionPaths =
                 toSpecsAndPaths(partitions, 
coreOptions.formatTablePartitionOnlyValueInPath());
-        return new CatalogPartitionListing(partitions, partitionPaths);
+        Map<Map<String, String>, String> formats = new HashMap<>();
+        for (Partition partition : partitions) {
+            Map<String, String> spec =
+                    normalizeSpec(
+                            partition.spec(), 
coreOptions.formatTablePartitionOnlyValueInPath());
+            String format =
+                    FormatTablePartitionOptions.fileFormat(table.options(), 
partition.options());
+            String previous = formats.putIfAbsent(spec, format);
+            if (previous != null && !previous.equals(format)) {
+                throw new IllegalStateException(
+                        "Catalog returned conflicting file formats for 
partition "
+                                + spec
+                                + " of Format Table "
+                                + table.fullName());
+            }
+        }
+        return new CatalogPartitionListing(partitions, partitionPaths, 
formats);
     }
 
     @Override
@@ -197,12 +218,15 @@ final class CatalogSplitEnumerator extends 
SplitEnumerator {
 
         private final List<Partition> partitions;
         private final List<Pair<LinkedHashMap<String, String>, Path>> 
partitionPaths;
+        private final Map<Map<String, String>, String> formats;
 
         private CatalogPartitionListing(
                 List<Partition> partitions,
-                List<Pair<LinkedHashMap<String, String>, Path>> 
partitionPaths) {
+                List<Pair<LinkedHashMap<String, String>, Path>> partitionPaths,
+                Map<Map<String, String>, String> formats) {
             this.partitions = partitions;
             this.partitionPaths = partitionPaths;
+            this.formats = formats;
         }
     }
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatBatchWriteBuilder.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatBatchWriteBuilder.java
index f9d08a5c1b..a57bf1fb97 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatBatchWriteBuilder.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatBatchWriteBuilder.java
@@ -100,7 +100,8 @@ public class FormatBatchWriteBuilder implements 
BatchWriteBuilder {
                 table.partitionManager(),
                 options.dynamicPartitionOverwrite(),
                 cleanupThreadNum,
-                publishThreadNum);
+                publishThreadNum,
+                FormatTablePartitionOptions.fileFormat(table.options(), null));
     }
 
     @Override
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatDataSplit.java 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatDataSplit.java
index 614780e425..eebfe60be6 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatDataSplit.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatDataSplit.java
@@ -40,16 +40,21 @@ public class FormatDataSplit implements Split {
     private final List<FileMeta> files;
     @Nullable private final BinaryRow partition;
     private final boolean useCatalogContextFileIO;
+    @Nullable private final String fileFormat;
 
     public FormatDataSplit(List<FileMeta> files, @Nullable BinaryRow 
partition) {
-        this(files, partition, false);
+        this(files, partition, false, null);
     }
 
     public FormatDataSplit(
-            List<FileMeta> files, @Nullable BinaryRow partition, boolean 
useCatalogContextFileIO) {
+            List<FileMeta> files,
+            @Nullable BinaryRow partition,
+            boolean useCatalogContextFileIO,
+            @Nullable String fileFormat) {
         this.files = files;
         this.partition = partition;
         this.useCatalogContextFileIO = useCatalogContextFileIO;
+        this.fileFormat = fileFormat;
     }
 
     public List<FileMeta> files() {
@@ -69,6 +74,14 @@ public class FormatDataSplit implements Split {
         return useCatalogContextFileIO;
     }
 
+    /**
+     * The planned format, or null for splits serialized before partition 
formats were supported.
+     */
+    @Nullable
+    public String fileFormat() {
+        return fileFormat;
+    }
+
     /** Total bytes to read for this split, i.e. the sum of {@link 
FileMeta#readSize()}. */
     public long totalSize() {
         return files.stream().mapToLong(FileMeta::readSize).sum();
@@ -99,13 +112,14 @@ public class FormatDataSplit implements Split {
         }
         FormatDataSplit that = (FormatDataSplit) o;
         return useCatalogContextFileIO == that.useCatalogContextFileIO
+                && Objects.equals(fileFormat, that.fileFormat)
                 && Objects.equals(files, that.files)
                 && Objects.equals(partition, that.partition);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(files, partition, useCatalogContextFileIO);
+        return Objects.hash(files, partition, useCatalogContextFileIO, 
fileFormat);
     }
 
     /**
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
index 1bdc1dbb6b..9e7cb1d349 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
@@ -217,9 +217,18 @@ public class FormatReadBuilder implements ReadBuilder {
                         PredicateBuilder.splitAnd(filter), new 
HashSet<>(table.partitionKeys()));
         RowType dataRowType = getRowTypeWithoutPartition(table.rowType(), 
table.partitionKeys());
         RowType readRowType = getRowTypeWithoutPartition(readType, 
table.partitionKeys());
+        Options fileOptions = new Options(options.toConfiguration().toMap());
+        fileOptions.set(
+                CoreOptions.FILE_FORMAT,
+                FormatTablePartitionOptions.fileFormat(
+                        fileOptions.toMap(),
+                        dataSplit.fileFormat() == null
+                                ? null
+                                : Collections.singletonMap(
+                                        CoreOptions.FILE_FORMAT.key(), 
dataSplit.fileFormat())));
         FormatReaderFactory readerFactory =
-                FileFormatDiscover.of(options)
-                        .discover(options.formatType())
+                FileFormatDiscover.of(new CoreOptions(fileOptions))
+                        .discover(fileOptions.get(CoreOptions.FILE_FORMAT))
                         .createReaderFactory(dataRowType, readRowType, 
readFilters);
 
         Pair<int[], RowType> partitionMapping =
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
index b0b0f5de45..d71ab7e258 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
@@ -102,7 +102,13 @@ public class FormatTableCommit implements BatchTableCommit 
{
     private final boolean dynamicPartitionOverwrite;
     private final int cleanupThreadNum;
     private final int publishThreadNum;
+    @Nullable private final String fileFormat;
 
+    /**
+     * Creates a legacy committer whose write format is unknown. Appends and 
overwrites reject
+     * partitions with an explicit {@code file.format}, including values 
stored by earlier
+     * overwrites. Use {@code FormatTable.newBatchWriteBuilder()} to supply 
the write format.
+     */
     public FormatTableCommit(
             String location,
             List<String> partitionKeys,
@@ -130,7 +136,8 @@ public class FormatTableCommit implements BatchTableCommit {
                 partitionManager,
                 dynamicPartitionOverwrite,
                 1,
-                1);
+                1,
+                null);
     }
 
     FormatTableCommit(
@@ -147,7 +154,8 @@ public class FormatTableCommit implements BatchTableCommit {
             @Nullable FormatTablePartitionManager partitionManager,
             boolean dynamicPartitionOverwrite,
             int cleanupThreadNum,
-            int publishThreadNum) {
+            int publishThreadNum,
+            @Nullable String fileFormat) {
         if (cleanupThreadNum < 1 || cleanupThreadNum > MAX_COMMIT_THREAD_NUM) {
             throw new IllegalArgumentException(
                     String.format(
@@ -174,6 +182,12 @@ public class FormatTableCommit implements BatchTableCommit 
{
         this.dynamicPartitionOverwrite = dynamicPartitionOverwrite;
         this.cleanupThreadNum = cleanupThreadNum;
         this.publishThreadNum = publishThreadNum;
+        this.fileFormat =
+                fileFormat == null
+                        ? null
+                        : FormatTablePartitionOptions.fileFormatOverride(
+                                Collections.singletonMap(
+                                        CoreOptions.FILE_FORMAT.key(), 
fileFormat));
         if (syncHiveUri != null) {
             try {
                 Options options = new Options();
@@ -319,7 +333,8 @@ public class FormatTableCommit implements BatchTableCommit {
                 message.getCommitter().clean(this.fileIO);
             }
             if (reportsStatistics && overwrite) {
-                reportPartitions(reportTargetSpecs, statisticsByPartition, 
commitTime, overwrite);
+                reportPartitions(
+                        reportTargetSpecs, statisticsByPartition, commitTime, 
true, fileFormat);
             } else if (partitionManager != null && !partitionSpecs.isEmpty()) {
                 // Register an append before reporting its additive 
statistics. Registration is
                 // idempotent, so a failed multi-batch call can roll back 
every file from this
@@ -351,7 +366,8 @@ public class FormatTableCommit implements BatchTableCommit {
                 markPublishedTargetsToPreserveOnAbort(messages);
                 if (reportsStatistics && !statisticsByPartition.isEmpty()) {
                     try {
-                        reportPartitions(partitionSpecs, 
statisticsByPartition, commitTime, false);
+                        reportPartitions(
+                                partitionSpecs, statisticsByPartition, 
commitTime, false, null);
                     } catch (RuntimeException statisticsFailure) {
                         LOG.warn(
                                 "Committed data for format table {}, but 
failed to report append "
@@ -417,11 +433,35 @@ public class FormatTableCommit implements 
BatchTableCommit {
 
         try {
             List<Partition> targetPartitions = 
loadCommitTargetPartitions(writtenPartitionSpecs);
-            if (!overwrite) {
+            if (!overwrite || fileFormat == null) {
                 for (Partition partition : targetPartitions) {
-                    if 
(FormatTablePartitionPathResolver.customLocation(partition) != null) {
+                    if (!overwrite
+                            && 
FormatTablePartitionPathResolver.customLocation(partition) != null) {
                         throw unsupportedCustomLocation("Writing", partition);
                     }
+                    String partitionFormat =
+                            
FormatTablePartitionOptions.fileFormatOverride(partition.options());
+                    if (partitionFormat != null && fileFormat == null) {
+                        throw new UnsupportedOperationException(
+                                "Cannot write to partition "
+                                        + partition.spec()
+                                        + " of Format Table "
+                                        + tableIdentifier
+                                        + " with an explicit file.format 
because the write format is"
+                                        + " unknown. Use 
FormatTable.newBatchWriteBuilder().");
+                    }
+                    if (partitionFormat != null && 
!partitionFormat.equals(fileFormat)) {
+                        throw new UnsupportedOperationException(
+                                "Cannot append files in format "
+                                        + fileFormat
+                                        + " to partition "
+                                        + partition.spec()
+                                        + " of Format Table "
+                                        + tableIdentifier
+                                        + " registered with file.format="
+                                        + partitionFormat
+                                        + ".");
+                    }
                 }
             }
             return targetPartitions;
@@ -437,14 +477,21 @@ public class FormatTableCommit implements 
BatchTableCommit {
             Set<Map<String, String>> writtenPartitionSpecs) {
         if (overwrite) {
             if (staticPartitions == null || staticPartitions.isEmpty()) {
-                return replacesOnlyWrittenPartitions()
-                        ? Collections.emptyList()
-                        : loadPartitionRegistry();
+                if (!replacesOnlyWrittenPartitions()) {
+                    return loadPartitionRegistry();
+                }
+            } else {
+                LinkedHashMap<String, String> staticSpec = 
orderedPartitionPrefix(staticPartitions);
+                if (staticSpec.size() < partitionKeys.size()) {
+                    return loadPartitionsByPrefix(staticSpec);
+                }
+                if (fileFormat == null) {
+                    return 
loadPartitionsByNames(Collections.singleton(staticSpec));
+                }
+            }
+            if (fileFormat != null) {
+                return Collections.emptyList();
             }
-            LinkedHashMap<String, String> staticSpec = 
orderedPartitionPrefix(staticPartitions);
-            return staticSpec.size() == partitionKeys.size()
-                    ? Collections.emptyList()
-                    : loadPartitionsByPrefix(staticSpec);
         }
 
         return writtenPartitionSpecs.isEmpty()
@@ -585,7 +632,8 @@ public class FormatTableCommit implements BatchTableCommit {
             Set<Map<String, String>> targetPartitionSpecs,
             Map<Map<String, String>, PartitionStatistics> 
statisticsByPartition,
             long commitTime,
-            boolean replaceStatistics) {
+            boolean replaceStatistics,
+            @Nullable String writtenFileFormat) {
         // Statistics are matched by spec, not by position: the specs need 
only be a superset.
         Set<Map<String, String>> specs = new 
LinkedHashSet<>(targetPartitionSpecs);
         specs.addAll(statisticsByPartition.keySet());
@@ -608,8 +656,14 @@ public class FormatTableCommit implements BatchTableCommit 
{
                     // require a fully qualified URI. Keep escaped partition 
values intact.
                     partitionPath = new Path(new 
File(partitionPath.toString()).toURI());
                 }
-                partitionOptions.add(
-                        Collections.singletonMap(CoreOptions.PATH.key(), 
partitionPath.toString()));
+                Map<String, String> options = new LinkedHashMap<>();
+                options.put(CoreOptions.PATH.key(), partitionPath.toString());
+                if (writtenFileFormat != null) {
+                    // Report the format of the replacement even when the 
registry previously
+                    // matched it. That earlier value may have changed since 
the lookup.
+                    options.put(CoreOptions.FILE_FORMAT.key(), 
writtenFileFormat);
+                }
+                partitionOptions.add(options);
             }
         }
         partitionManager.createPartitions(
@@ -1314,7 +1368,11 @@ public class FormatTableCommit implements 
BatchTableCommit {
             // too, so the catalog stops describing files that are gone.
             try {
                 reportPartitions(
-                        emptied.keySet(), emptied, truncateTime, /* 
replaceStatistics */ true);
+                        emptied.keySet(),
+                        emptied,
+                        truncateTime,
+                        /* replaceStatistics */ true,
+                        null);
             } catch (RuntimeException e) {
                 if (failure == null) {
                     throw e;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionManager.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionManager.java
index aebbcfd1fb..0053bbda57 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionManager.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionManager.java
@@ -85,6 +85,11 @@ public interface FormatTablePartitionManager extends 
Serializable {
      * and may split the request into catalog-sized batches; each batch is 
atomic, the whole call is
      * not.
      *
+     * <p>A partition's {@code file.format} overrides the table format. 
Supplying it explicitly
+     * updates that partition's format even when {@code ignoreIfExists=true}; 
omitting it preserves
+     * the stored override. The format and statistics are updated in the same 
batch. A catalog must
+     * reject additive statistics whose supplied format differs from the 
registered format.
+     *
      * <p>This is the method an implementation provides, so that none can 
report nothing by
      * accident: a decorator that forwards only the two-argument form would 
otherwise drop every
      * report and leave the caller no way to notice.
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionOptions.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionOptions.java
new file mode 100644
index 0000000000..da4d46b258
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionOptions.java
@@ -0,0 +1,61 @@
+/*
+ * 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.table.format;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.table.FormatTable;
+
+import javax.annotation.Nullable;
+
+import java.util.Locale;
+import java.util.Map;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Resolves the file format stored in a Format Table partition's options. */
+public final class FormatTablePartitionOptions {
+
+    private FormatTablePartitionOptions() {}
+
+    public static String fileFormat(
+            Map<String, String> tableOptions, @Nullable Map<String, String> 
partitionOptions) {
+        String override = fileFormatOverride(partitionOptions);
+        if (override != null) {
+            return override;
+        }
+        String tableFormat = fileFormatOverride(tableOptions);
+        return tableFormat == null ? CoreOptions.FILE_FORMAT.defaultValue() : 
tableFormat;
+    }
+
+    /**
+     * Returns only an explicit override; defaults must be applied after 
inheriting table options.
+     */
+    @Nullable
+    public static String fileFormatOverride(@Nullable Map<String, String> 
options) {
+        if (options == null || 
!options.containsKey(CoreOptions.FILE_FORMAT.key())) {
+            return null;
+        }
+        String value = options.get(CoreOptions.FILE_FORMAT.key());
+        checkArgument(
+                value != null && !value.trim().isEmpty(),
+                "Format Table option '%s' must not be null or empty.",
+                CoreOptions.FILE_FORMAT.key());
+        return FormatTable.parseFormat(value).name().toLowerCase(Locale.ROOT);
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionStatsCollector.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionStatsCollector.java
index 09f1eceea5..3d941df50a 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionStatsCollector.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionStatsCollector.java
@@ -23,6 +23,8 @@ import org.apache.paimon.format.FileFormat;
 import org.apache.paimon.format.SimpleStatsExtractor;
 import org.apache.paimon.fs.FileStatus;
 import org.apache.paimon.fs.Path;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.partition.Partition;
 import org.apache.paimon.partition.PartitionStatistics;
 import org.apache.paimon.statistics.SimpleColStatsCollector;
 import org.apache.paimon.table.FormatTable;
@@ -40,6 +42,7 @@ import java.io.IOException;
 import java.io.UncheckedIOException;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -104,23 +107,68 @@ public class FormatTablePartitionStatsCollector {
      * caller can send it straight to the catalog alongside the same specs.
      */
     public List<PartitionStatistics> collect(List<Map<String, String>> 
partitions) {
+        List<Partition> registered =
+                partitions.isEmpty() || table.partitionManager() == null
+                        ? Collections.emptyList()
+                        : 
table.partitionManager().listPartitionsByNames(partitions);
+        return collect(partitions, partitionFormats(registered));
+    }
+
+    /**
+     * Measures the same partition metadata that the caller planned, without 
reloading the catalog.
+     */
+    public List<PartitionStatistics> collectPartitions(List<Partition> 
partitions) {
+        List<Map<String, String>> specs = new ArrayList<>(partitions.size());
+        for (Partition partition : partitions) {
+            specs.add(partition.spec());
+        }
+        return collect(specs, partitionFormats(partitions));
+    }
+
+    private Map<Map<String, String>, String> partitionFormats(List<Partition> 
partitions) {
+        Map<Map<String, String>, String> formats = new HashMap<>();
+        for (Partition partition : partitions) {
+            if (FormatTablePartitionPathResolver.customLocation(partition) != 
null) {
+                throw new UnsupportedOperationException(
+                        "Cannot measure a Format Table partition with a custom 
location: "
+                                + partition.spec());
+            }
+            String format =
+                    FormatTablePartitionOptions.fileFormat(table.options(), 
partition.options());
+            String previous = formats.putIfAbsent(partition.spec(), format);
+            if (previous != null && !previous.equals(format)) {
+                throw new IllegalStateException(
+                        "Conflicting file formats for partition " + 
partition.spec());
+            }
+        }
+        return formats;
+    }
+
+    private List<PartitionStatistics> collect(
+            List<Map<String, String>> partitions, Map<Map<String, String>, 
String> formats) {
         if (partitions.isEmpty()) {
             return Collections.emptyList();
         }
-        SimpleStatsExtractor rowCounter = withRecordCount ? rowCounter() : 
null;
-        if (withRecordCount && rowCounter == null) {
-            LOG.info(
-                    "No row counter could be built for format {} of table {}, 
so the row counts of "
-                            + "the measured partitions stay unknown.",
-                    table.format(),
-                    table.fullName());
+        String defaultFormat = 
FormatTablePartitionOptions.fileFormat(table.options(), null);
+        Map<String, Optional<SimpleStatsExtractor>> counters = new HashMap<>();
+        List<SimpleStatsExtractor> rowCounters = new 
ArrayList<>(partitions.size());
+        for (Map<String, String> partition : partitions) {
+            String format = formats.getOrDefault(partition, defaultFormat);
+            rowCounters.add(
+                    withRecordCount
+                            ? counters.computeIfAbsent(
+                                            format, f -> 
Optional.ofNullable(rowCounter(f)))
+                                    .orElse(null)
+                            : null);
         }
         // A listing is one request per partition, a footer read one per file, 
so counting rows
         // leaves work to spread even when a single partition was asked for.
-        int threads = rowCounter == null ? Math.min(parallelism, 
partitions.size()) : parallelism;
+        int threads = withRecordCount ? parallelism : Math.min(parallelism, 
partitions.size());
         if (threads == 1) {
             List<PartitionStatistics> statistics = new 
ArrayList<>(partitions.size());
-            for (Map<String, String> partition : partitions) {
+            for (int i = 0; i < partitions.size(); i++) {
+                Map<String, String> partition = partitions.get(i);
+                SimpleStatsExtractor rowCounter = rowCounters.get(i);
                 List<FileStatus> files = listDataFiles(partition);
                 List<Long> rowCounts = new ArrayList<>(files.size());
                 for (FileStatus file : files) {
@@ -147,7 +195,9 @@ public class FormatTablePartitionStatsCollector {
             // Every file of every partition goes to the same pool, so one 
partition holding many
             // files is counted with all of it rather than with one thread of 
it.
             List<List<Future<Long>>> rowCounts = new 
ArrayList<>(partitions.size());
-            for (List<FileStatus> partitionFiles : files) {
+            for (int i = 0; i < files.size(); i++) {
+                List<FileStatus> partitionFiles = files.get(i);
+                SimpleStatsExtractor rowCounter = rowCounters.get(i);
                 List<Future<Long>> counts = new 
ArrayList<>(partitionFiles.size());
                 for (FileStatus file : partitionFiles) {
                     if (rowCounter != null) {
@@ -164,7 +214,9 @@ public class FormatTablePartitionStatsCollector {
                 }
                 statistics.add(
                         statistics(
-                                partitions.get(i), files.get(i), sum(counted, 
rowCounter != null)));
+                                partitions.get(i),
+                                files.get(i),
+                                sum(counted, rowCounters.get(i) != null)));
             }
             return statistics;
         } finally {
@@ -271,9 +323,11 @@ public class FormatTablePartitionStatsCollector {
      * table's.
      */
     @Nullable
-    private SimpleStatsExtractor rowCounter() {
+    private SimpleStatsExtractor rowCounter(String fileFormat) {
         try {
-            CoreOptions options = new CoreOptions(table.options());
+            Options fileOptions = new Options(table.options());
+            fileOptions.set(CoreOptions.FILE_FORMAT, fileFormat);
+            CoreOptions options = new CoreOptions(fileOptions);
             Optional<SimpleStatsExtractor> extractor =
                     
FileFormat.fileFormat(options).createStatsExtractor(NO_COLUMNS, NO_COLLECTORS);
             return extractor.orElse(null);
@@ -281,7 +335,7 @@ public class FormatTablePartitionStatsCollector {
             LOG.warn(
                     "Failed to create a row counter for format {} of table {}; 
row counts stay "
                             + "unknown.",
-                    table.format(),
+                    fileFormat,
                     table.fullName(),
                     e);
             return null;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/SplitEnumerator.java 
b/paimon-core/src/main/java/org/apache/paimon/table/format/SplitEnumerator.java
index b6578fd859..882551c833 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/SplitEnumerator.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/SplitEnumerator.java
@@ -63,14 +63,12 @@ abstract class SplitEnumerator {
     protected final CoreOptions coreOptions;
     protected final long targetSplitSize;
     protected final long openFileCost;
-    protected final FormatTable.Format format;
 
     SplitEnumerator(FormatTable table, CoreOptions coreOptions) {
         this.table = table;
         this.coreOptions = coreOptions;
         this.targetSplitSize = coreOptions.splitTargetSize();
         this.openFileCost = coreOptions.splitOpenFileCost();
-        this.format = table.format();
     }
 
     static SplitEnumerator create(
@@ -145,21 +143,28 @@ abstract class SplitEnumerator {
 
     List<Split> createSplits(FileIO fileIO, Path path, @Nullable BinaryRow 
partition)
             throws IOException {
-        return createSplits(fileIO, path, partition, false);
+        return createSplits(
+                fileIO,
+                path,
+                partition,
+                false,
+                FormatTablePartitionOptions.fileFormat(table.options(), null));
     }
 
     List<Split> createSplits(
             FileIO fileIO,
             Path path,
             @Nullable BinaryRow partition,
-            boolean useCatalogContextFileIO)
+            boolean useCatalogContextFileIO,
+            String fileFormat)
             throws IOException {
+        FormatTable.Format partitionFormat = 
FormatTable.parseFormat(fileFormat);
         List<FormatDataSplit.FileMeta> segments = new ArrayList<>();
         // The listed directory is a single partition, or the table itself 
when unpartitioned.
         List<FileStatus> files = FormatTableScan.listDataFiles(fileIO, path);
         files.sort(Comparator.comparing(file -> file.getPath().toString()));
         for (FileStatus file : files) {
-            segments.addAll(toSegments(file));
+            segments.addAll(toSegments(file, partitionFormat));
         }
 
         List<Split> splits = new ArrayList<>();
@@ -168,13 +173,14 @@ abstract class SplitEnumerator {
                         segments,
                         file -> Math.max(file.readSize(), openFileCost),
                         targetSplitSize)) {
-            splits.add(new FormatDataSplit(bin, partition, 
useCatalogContextFileIO));
+            splits.add(new FormatDataSplit(bin, partition, 
useCatalogContextFileIO, fileFormat));
         }
         return splits;
     }
 
-    private List<FormatDataSplit.FileMeta> toSegments(FileStatus file) {
-        if (!preferToSplitFile(file)) {
+    private List<FormatDataSplit.FileMeta> toSegments(
+            FileStatus file, FormatTable.Format fileFormat) {
+        if (!preferToSplitFile(file, fileFormat)) {
             return Collections.singletonList(
                     new FormatDataSplit.FileMeta(file.getPath(), 
file.getLen()));
         }
@@ -193,13 +199,13 @@ abstract class SplitEnumerator {
         return segments;
     }
 
-    private boolean preferToSplitFile(FileStatus file) {
+    private boolean preferToSplitFile(FileStatus file, FormatTable.Format 
fileFormat) {
         if (file.getLen() <= targetSplitSize) {
             return false;
         }
 
         Options options = coreOptions.toConfiguration();
-        switch (format) {
+        switch (fileFormat) {
             case CSV:
                 return !isCompressed(file.getPath())
                         && 
isDefaultDelimiter(options.get(CsvOptions.LINE_DELIMITER));
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java 
b/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
index 1b82f86cd6..6e5c96ccf1 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
@@ -909,7 +909,11 @@ class MockRESTCatalogTest extends RESTCatalogTest {
         assertThat(partition.recordCount()).isZero();
         assertThat(partition.fileSizeInBytes()).isZero();
         assertThat(partition.fileCount()).isZero();
-        assertThat(partition.options()).isEqualTo(partitionOptions);
+        Map<String, String> expectedOptions = new HashMap<>(partitionOptions);
+        if (operation.equals("overwrite")) {
+            expectedOptions.put(CoreOptions.FILE_FORMAT.key(), "parquet");
+        }
+        assertThat(partition.options()).isEqualTo(expectedOptions);
     }
 
     @Test
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogPartitionSupport.java
 
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogPartitionSupport.java
index 28de8abd30..af898f52c6 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogPartitionSupport.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogPartitionSupport.java
@@ -28,6 +28,7 @@ import org.apache.paimon.partition.PartitionStatistics;
 import org.apache.paimon.partition.PartitionUtils;
 import org.apache.paimon.rest.requests.CreatePartitionsRequest;
 import org.apache.paimon.rest.responses.ErrorResponse;
+import org.apache.paimon.table.format.FormatTablePartitionOptions;
 import org.apache.paimon.table.format.FormatTablePartitionPathResolver;
 import org.apache.paimon.table.format.FormatTablePartitionRegistryValidator;
 import org.apache.paimon.utils.StringUtils;
@@ -94,6 +95,10 @@ final class RESTCatalogPartitionSupport {
         for (int i = 0; i < options.size(); i++) {
             Map<String, String> partitionOptions = options.get(i);
             Map<String, String> copied = new HashMap<>(partitionOptions);
+            String format = 
FormatTablePartitionOptions.fileFormatOverride(copied);
+            if (format != null) {
+                copied.put(CoreOptions.FILE_FORMAT.key(), format);
+            }
             String location = copied.get(PATH.key());
             if (location != null) {
                 // What a partition may own is judged once the location is 
known not to be the
@@ -168,6 +173,50 @@ final class RESTCatalogPartitionSupport {
         return copied;
     }
 
+    static void applyRequestedFormats(
+            List<Partition> stored,
+            CreatePartitionsRequest request,
+            @Nullable List<Map<String, String>> options,
+            TableMetadata metadata) {
+        if (options == null) {
+            return;
+        }
+        Map<Map<String, String>, String> formats = new HashMap<>();
+        for (int i = 0; i < options.size(); i++) {
+            String format = 
FormatTablePartitionOptions.fileFormatOverride(options.get(i));
+            if (format != null) {
+                formats.put(request.getPartitionSpecs().get(i), format);
+            }
+        }
+        Set<Map<String, String>> appended = new HashSet<>();
+        if (!Boolean.TRUE.equals(request.replaceStatistics())
+                && request.getPartitionStatistics() != null) {
+            for (PartitionStatistics statistic : 
request.getPartitionStatistics()) {
+                appended.add(statistic.spec());
+            }
+        }
+        for (int i = 0; i < stored.size(); i++) {
+            Partition partition = stored.get(i);
+            String format = formats.get(partition.spec());
+            if (format == null) {
+                continue;
+            }
+            if (appended.contains(partition.spec())
+                    && !format.equals(
+                            FormatTablePartitionOptions.fileFormat(
+                                    metadata.schema().options(), 
partition.options()))) {
+                throw new IllegalArgumentException(
+                        "Cannot append a different file format to partition " 
+ partition.spec());
+            }
+            Map<String, String> updated =
+                    partition.options() == null
+                            ? new HashMap<>()
+                            : new HashMap<>(partition.options());
+            updated.put(CoreOptions.FILE_FORMAT.key(), format);
+            stored.set(i, copyPartition(partition, updated));
+        }
+    }
+
     private static void validateNoAdditiveStatisticsForCustomPartitions(
             List<Partition> stored,
             @Nullable List<PartitionStatistics> statistics,
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java 
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
index d196e3be23..b4afe6adaf 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
@@ -2270,6 +2270,10 @@ public class RESTCatalogServer {
                         existed.add(spec);
                     }
                 }
+                if (formatTable) {
+                    RESTCatalogPartitionSupport.applyRequestedFormats(
+                            storedPartitions, request, requestedOptions, 
tableMetadata);
+                }
                 RESTCatalogPartitionSupport.settlePartitionLocations(
                         storedPartitions,
                         returningToDefault,
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/FormatTableCommitCompatibilityTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/FormatTableCommitCompatibilityTest.java
index 632ea63c46..595d13ae3f 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/FormatTableCommitCompatibilityTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/FormatTableCommitCompatibilityTest.java
@@ -20,13 +20,36 @@ package org.apache.paimon.table;
 
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.RenamingTwoPhaseOutputStream;
 import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.partition.Partition;
 import org.apache.paimon.table.format.FormatTableCommit;
+import org.apache.paimon.table.format.FormatTablePartitionManager;
+import org.apache.paimon.table.format.TwoPhaseCommitMessage;
+import org.apache.paimon.table.sink.CommitMessage;
 
 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.CsvSource;
 
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
 import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 /** Compatibility tests for the public Format Table commit API. */
 class FormatTableCommitCompatibilityTest {
@@ -53,4 +76,102 @@ class FormatTableCommitCompatibilityTest {
             commit.commit(Collections.emptyList());
         }
     }
+
+    @ParameterizedTest
+    @CsvSource({
+        "append, false",
+        "append, true",
+        "static, false",
+        "static, true",
+        "dynamic, false",
+        "dynamic, true",
+        "prefix, false",
+        "prefix, true",
+        "table, false",
+        "table, true",
+        "empty-static, false",
+        "empty-static, true"
+    })
+    void testLegacyConstructorRequiresWriteFormatForExplicitPartitions(
+            String operation, boolean explicitFormat) throws Exception {
+        Path tablePath = new Path(tempDir.toUri());
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path partitionPath = new Path(tablePath, "pt=p/sub=s");
+        fileIO.mkdirs(partitionPath);
+        Path oldFile = new Path(partitionPath, "old.parquet");
+        Path newFile = new Path(partitionPath, "new.parquet");
+        fileIO.overwriteFileUtf8(oldFile, "old");
+        List<CommitMessage> messages;
+        if (operation.equals("empty-static")) {
+            messages = Collections.emptyList();
+        } else {
+            RenamingTwoPhaseOutputStream stream =
+                    new RenamingTwoPhaseOutputStream(fileIO, newFile, false);
+            stream.write("replacement".getBytes(StandardCharsets.UTF_8));
+            messages =
+                    Collections.singletonList(new 
TwoPhaseCommitMessage(stream.closeForCommit()));
+        }
+
+        Map<String, String> spec = new LinkedHashMap<>();
+        spec.put("pt", "p");
+        spec.put("sub", "s");
+        Partition partition =
+                new Partition(
+                        spec,
+                        1,
+                        3,
+                        1,
+                        0,
+                        -1,
+                        false,
+                        null,
+                        null,
+                        null,
+                        null,
+                        explicitFormat ? 
Collections.singletonMap("file.format", "parquet") : null);
+        FormatTablePartitionManager manager = 
mock(FormatTablePartitionManager.class);
+        when(manager.listPartitions(anyMap(), any()))
+                .thenReturn(Collections.singletonList(partition));
+        when(manager.listPartitionsByNames(anyList()))
+                .thenReturn(Collections.singletonList(partition));
+        Map<String, String> staticSpec = Collections.emptyMap();
+        if (operation.equals("static") || operation.equals("empty-static")) {
+            staticSpec = spec;
+        } else if (operation.equals("prefix")) {
+            staticSpec = Collections.singletonMap("pt", "p");
+        }
+
+        try (FormatTableCommit commit =
+                new FormatTableCommit(
+                        tablePath.toString(),
+                        Arrays.asList("pt", "sub"),
+                        fileIO,
+                        false,
+                        "__DEFAULT_PARTITION__",
+                        !operation.equals("append"),
+                        Identifier.create("compatibility_db", 
"compatibility_table"),
+                        staticSpec,
+                        null,
+                        null,
+                        manager,
+                        operation.equals("dynamic"))) {
+            if (explicitFormat) {
+                assertThatThrownBy(() -> commit.commit(messages))
+                        
.hasRootCauseInstanceOf(UnsupportedOperationException.class)
+                        .hasStackTraceContaining("write format is unknown")
+                        
.hasStackTraceContaining("FormatTable.newBatchWriteBuilder()");
+                assertThat(fileIO.readFileUtf8(oldFile)).isEqualTo("old");
+                assertThat(fileIO.exists(newFile)).isFalse();
+                verify(manager, never())
+                        .createPartitions(anyList(), anyBoolean(), any(), 
anyBoolean(), any());
+            } else {
+                commit.commit(messages);
+                
assertThat(fileIO.exists(oldFile)).isEqualTo(operation.equals("append"));
+                
assertThat(fileIO.exists(newFile)).isEqualTo(!messages.isEmpty());
+            }
+        }
+        if (!messages.isEmpty()) {
+            assertThat(fileIO.listStatus(new Path(partitionPath, 
"_temporary"))).isEmpty();
+        }
+    }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatDataSplitTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatDataSplitTest.java
index 45211c29a1..c2330df900 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatDataSplitTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatDataSplitTest.java
@@ -25,6 +25,7 @@ import org.apache.paimon.utils.InstantiationUtil;
 import org.junit.jupiter.api.Test;
 
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.ObjectStreamClass;
 import java.util.Arrays;
 
@@ -73,7 +74,8 @@ public class FormatDataSplitTest {
                 new FormatDataSplit(
                         Arrays.asList(new FileMeta(new 
Path("oss://archive/data.csv"), 10L)),
                         null,
-                        true);
+                        true,
+                        null);
 
         FormatDataSplit deserialized =
                 InstantiationUtil.deserializeObject(
@@ -83,6 +85,24 @@ public class FormatDataSplitTest {
         assertThat(deserialized.useCatalogContextFileIO()).isTrue();
     }
 
+    @Test
+    public void testDeserializeSplitWithoutFileFormat() throws Exception {
+        // Serialized by FormatDataSplit at ce0633cb09, before the fileFormat 
field was added.
+        try (InputStream input =
+                getClass()
+                        .getClassLoader()
+                        
.getResourceAsStream("compatibility/format-data-split-v3")) {
+            assertThat(input).isNotNull();
+            FormatDataSplit split =
+                    InstantiationUtil.deserializeObject(input, 
getClass().getClassLoader());
+            assertThat(split.fileFormat()).isNull();
+            assertThat(split.useCatalogContextFileIO()).isTrue();
+            assertThat(split.partition()).isNull();
+            assertThat(split.files())
+                    .containsExactly(new FileMeta(new 
Path("/legacy/file.orc"), 128L));
+        }
+    }
+
     @Test
     public void testSerialVersionUIDsStayCompatible() {
         
assertThat(ObjectStreamClass.lookup(FormatDataSplit.class).getSerialVersionUID())
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatReadBuilderTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatReadBuilderTest.java
index 3e48b5e9b2..e1b08424d2 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatReadBuilderTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatReadBuilderTest.java
@@ -310,7 +310,8 @@ public class FormatReadBuilderTest {
                                                         csvFile,
                                                         
clientFileIO.getFileSize(csvFile))),
                                         null,
-                                        true)),
+                                        true,
+                                        null)),
                         getClass().getClassLoader());
 
         List<InternalRow> rows = readAllRows(readBuilder.createReader(split), 
rowType);
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitStatisticsTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitStatisticsTest.java
index ef891793e1..b0f996191f 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitStatisticsTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitStatisticsTest.java
@@ -188,7 +188,8 @@ class FormatTableCommitStatisticsTest {
                 .containsExactly(returnsToDefault(tablePath, spec("2025", 
"10")));
         assertThat(fileIO.exists(oldWrittenPartitionData)).isFalse();
         verify(partitionManager, never()).listPartitions(any(), isNull());
-        verify(partitionManager, never()).listPartitionsByNames(anyList());
+        verify(partitionManager)
+                .listPartitionsByNames(Collections.singletonList(spec("2025", 
"10")));
         assertThat(reported.statistics).hasSize(1);
         PartitionStatistics statistics = reported.statistics.get(0);
         assertThat(statistics.recordCount()).isEqualTo(3);
@@ -197,8 +198,7 @@ class FormatTableCommitStatisticsTest {
     }
 
     @Test
-    void 
testEmptyStaticOverwriteReportsZeroAndResetsLocationWithoutRegistryRead()
-            throws Exception {
+    void testEmptyStaticOverwriteReportsZeroAndResetsLocation() throws 
Exception {
         LocalFileIO fileIO = LocalFileIO.create();
         Path tablePath = new Path(tempDir.toUri());
         FormatTablePartitionManager partitionManager = 
mock(FormatTablePartitionManager.class);
@@ -217,7 +217,7 @@ class FormatTableCommitStatisticsTest {
         assertThat(reported.partitionOptions)
                 .containsExactly(returnsToDefault(tablePath, spec("2025", 
"10")));
         verify(partitionManager, never()).listPartitions(any(), isNull());
-        verify(partitionManager, never()).listPartitionsByNames(anyList());
+        
verify(partitionManager).listPartitionsByNames(Collections.singletonList(target));
     }
 
     @Test
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitTest.java
index 24d957f97a..590d0398f5 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableCommitTest.java
@@ -234,7 +234,7 @@ class FormatTableCommitTest {
         assertThat(fileIO.exists(customData)).isTrue();
         assertThat(fileIO.deleteCalls()).isEqualTo(1);
         assertThat(fileIO.mkdirsCalls()).isZero();
-        verify(partitionManager, never()).listPartitionsByNames(anyList());
+        
verify(partitionManager).listPartitionsByNames(Collections.singletonList(spec));
         verify(partitionManager, 
never()).listPartitions(Collections.emptyMap(), null);
         assertReplacementReport(partitionManager, tablePath, 
Collections.singletonList(spec));
     }
@@ -280,7 +280,7 @@ class FormatTableCommitTest {
         assertThat(fileIO.exists(customData)).isTrue();
         assertThat(fileIO.deleteCalls()).isEqualTo(1);
         assertThat(fileIO.mkdirsCalls()).isZero();
-        verify(partitionManager, never()).listPartitionsByNames(anyList());
+        
verify(partitionManager).listPartitionsByNames(Collections.singletonList(targetSpec));
         verify(partitionManager, 
never()).listPartitions(Collections.emptyMap(), null);
         assertReplacementReport(partitionManager, tablePath, 
Collections.singletonList(targetSpec));
     }
@@ -420,7 +420,7 @@ class FormatTableCommitTest {
         assertThat(fileIO.mkdirsCalls()).isZero();
         verify(committer).commit(fileIO);
         verify(committer).clean(fileIO);
-        verify(partitionManager, never()).listPartitionsByNames(anyList());
+        
verify(partitionManager).listPartitionsByNames(Collections.singletonList(spec));
         verify(partitionManager, 
never()).listPartitions(Collections.emptyMap(), null);
         assertReplacementReport(partitionManager, tablePath, 
Collections.singletonList(spec));
     }
@@ -473,7 +473,7 @@ class FormatTableCommitTest {
         assertThat(fileIO.mkdirsCalls()).isZero();
         verify(committer).commit(fileIO);
         verify(committer).clean(fileIO);
-        verify(partitionManager, never()).listPartitionsByNames(anyList());
+        
verify(partitionManager).listPartitionsByNames(Collections.singletonList(targetSpec));
         verify(partitionManager, 
never()).listPartitions(Collections.emptyMap(), null);
         assertReplacementReport(partitionManager, tablePath, 
Collections.singletonList(targetSpec));
     }
@@ -615,7 +615,7 @@ class FormatTableCommitTest {
 
         assertThat(fileIO.exists(oldData)).isFalse();
         assertThat(fileIO.exists(new Path(tablePath, 
"part=default"))).isTrue();
-        verify(partitionManager, never()).listPartitionsByNames(anyList());
+        
verify(partitionManager).listPartitionsByNames(Collections.singletonList(targetSpec));
         verify(partitionManager, 
never()).listPartitions(Collections.emptyMap(), null);
         assertReplacementReport(partitionManager, tablePath, 
Collections.singletonList(targetSpec));
     }
@@ -2185,7 +2185,7 @@ class FormatTableCommitTest {
             assertThat(thirdFinished.await(3, TimeUnit.SECONDS)).isTrue();
             assertThat(activePublishes).hasValue(1);
             verify(partitionManager, never())
-                    .createPartitions(anyList(), eq(true), any(), 
anyBoolean(), isNull());
+                    .createPartitions(anyList(), eq(true), any(), 
anyBoolean(), any());
             for (TwoPhaseOutputStream.Committer committer : committers) {
                 verify(committer, never()).clean(fileIO);
             }
@@ -2770,7 +2770,8 @@ class FormatTableCommitTest {
                         partitionManager,
                         /* dynamicPartitionOverwrite */ true,
                         /* cleanupThreadNum */ 2,
-                        /* publishThreadNum */ 1);
+                        /* publishThreadNum */ 1,
+                        null);
 
         commit.commit(Collections.emptyList());
 
@@ -2853,7 +2854,8 @@ class FormatTableCommitTest {
                         partitionManager,
                         /* dynamicPartitionOverwrite */ true,
                         /* cleanupThreadNum */ 4,
-                        /* publishThreadNum */ 1);
+                        /* publishThreadNum */ 1,
+                        null);
 
         commit.commit(Collections.emptyList());
 
@@ -3069,7 +3071,8 @@ class FormatTableCommitTest {
                 null,
                 /* dynamicPartitionOverwrite */ true,
                 /* cleanupThreadNum */ 1,
-                publishThreadNum);
+                publishThreadNum,
+                null);
     }
 
     private void assertOverwriteCleanupSpansPartitions(boolean 
dynamicPartitionOverwrite)
@@ -3144,6 +3147,8 @@ class FormatTableCommitTest {
                         .field("part", DataTypes.STRING())
                         .field("id", DataTypes.INT())
                         .build();
+        Map<String, String> tableOptions = new LinkedHashMap<>(options);
+        tableOptions.put(CoreOptions.FILE_FORMAT.key(), "csv");
         FormatTable table =
                 FormatTable.builder()
                         .fileIO(fileIO)
@@ -3152,7 +3157,7 @@ class FormatTableCommitTest {
                         .partitionKeys(Collections.singletonList("part"))
                         .location(tablePath.toString())
                         .format(FormatTable.Format.CSV)
-                        .options(options)
+                        .options(tableOptions)
                         .partitionManager(partitionManager)
                         .build();
         return table;
@@ -3163,6 +3168,8 @@ class FormatTableCommitTest {
             FileIO fileIO,
             FormatTablePartitionManager partitionManager,
             Map<String, String> options) {
+        Map<String, String> tableOptions = new LinkedHashMap<>(options);
+        tableOptions.put(CoreOptions.FILE_FORMAT.key(), "csv");
         FormatTable table =
                 FormatTable.builder()
                         .fileIO(fileIO)
@@ -3171,7 +3178,7 @@ class FormatTableCommitTest {
                         .partitionKeys(Collections.emptyList())
                         .location(tablePath.toString())
                         .format(FormatTable.Format.CSV)
-                        .options(options)
+                        .options(tableOptions)
                         .partitionManager(partitionManager)
                         .build();
         BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
@@ -3199,7 +3206,8 @@ class FormatTableCommitTest {
                 partitionManager,
                 /* dynamicPartitionOverwrite */ true,
                 cleanupThreadNum,
-                /* publishThreadNum */ 1);
+                /* publishThreadNum */ 1,
+                null);
     }
 
     private static void writeOldFiles(LocalFileIO fileIO, Path partitionPath, 
int count)
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTablePartitionFormatTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTablePartitionFormatTest.java
new file mode 100644
index 0000000000..6f49567bb5
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTablePartitionFormatTest.java
@@ -0,0 +1,616 @@
+/*
+ * 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.table.format;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.catalog.CatalogContext;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.format.FileFormat;
+import org.apache.paimon.format.FormatWriter;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.partition.Partition;
+import org.apache.paimon.partition.PartitionStatistics;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.table.FormatTable;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.InstantiationUtil;
+
+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.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests partition format inheritance, file planning and reads through the 
shared core path. */
+class FormatTablePartitionFormatTest {
+
+    private static final RowType DATA_TYPE =
+            RowType.builder().field("value", DataTypes.STRING()).build();
+    private static final RowType TABLE_TYPE =
+            RowType.builder()
+                    .field("value", DataTypes.STRING())
+                    .field("pt", DataTypes.STRING())
+                    .build();
+
+    @TempDir java.nio.file.Path tempDir;
+
+    @Test
+    void testDefaultsAndExplicitInvalidValues() {
+        
assertThat(FormatTablePartitionOptions.fileFormat(Collections.emptyMap(), null))
+                .isEqualTo("parquet");
+        assertThat(FormatTablePartitionOptions.fileFormat(options("orc"), 
Collections.emptyMap()))
+                .isEqualTo("orc");
+        assertThat(FormatTablePartitionOptions.fileFormat(options("orc"), 
options("parquet")))
+                .isEqualTo("parquet");
+        
assertThat(FormatTablePartitionOptions.fileFormat(Collections.emptyMap(), 
options("ORC")))
+                .isEqualTo("orc");
+        for (String invalid : Arrays.asList(null, "", " ", "avro", "unknown")) 
{
+            assertThatThrownBy(
+                            () ->
+                                    FormatTablePartitionOptions.fileFormat(
+                                            options("orc"), options(invalid)))
+                    .isInstanceOf(RuntimeException.class);
+        }
+    }
+
+    @ParameterizedTest
+    @EnumSource(FormatTable.Format.class)
+    void testEveryTableFormatCanBeInheritedOrOverridden(FormatTable.Format 
format) {
+        String expected = format.name().toLowerCase(java.util.Locale.ROOT);
+        
assertThat(FormatTablePartitionOptions.fileFormat(options(format.name()), null))
+                .isEqualTo(expected);
+        assertThat(
+                        FormatTablePartitionOptions.fileFormat(
+                                options("parquet"), options(format.name())))
+                .isEqualTo(expected);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"orc", "parquet", "csv", "json", "text"})
+    void testMixedPartitionReadsAndStatistics(String override) throws 
Exception {
+        String inherited = override.equals("orc") ? "parquet" : "orc";
+        Path tablePath = new Path(tempDir.toUri());
+        writeFile(tablePath, "inherited", inherited, "base");
+        writeFile(tablePath, "overridden", override, "first", "second");
+        List<Partition> partitions =
+                Arrays.asList(
+                        partition("inherited", null), partition("overridden", 
options(override)));
+        FormatTable table = table(tablePath, inherited, partitions);
+
+        ReadBuilder read = table.newReadBuilder();
+        List<Split> splits = read.newScan().plan().splits();
+        assertThat(splits).hasSize(2);
+        for (Split split : splits) {
+            FormatDataSplit copy = InstantiationUtil.clone((FormatDataSplit) 
split);
+            assertThat(copy).isEqualTo(split);
+            assertThat(copy.fileFormat()).isIn(inherited, override);
+        }
+        assertThat(readRows(read, splits)).containsExactlyInAnyOrder("base", 
"first", "second");
+
+        ReadBuilder selected =
+                table.newReadBuilder()
+                        .withFilter(
+                                new PredicateBuilder(TABLE_TYPE)
+                                        .equal(1, 
BinaryString.fromString("overridden")))
+                        .withReadType(DATA_TYPE);
+        assertThat(readRows(selected, selected.newScan().plan().splits()))
+                .containsExactly("first", "second");
+
+        List<PartitionStatistics> statistics =
+                new FormatTablePartitionStatsCollector(table, true, 2)
+                        .collectPartitions(partitions);
+        assertThat(statistics.get(0).recordCount()).isEqualTo(1);
+        assertThat(statistics.get(1).recordCount())
+                .isEqualTo(
+                        override.equals("orc") || override.equals("parquet")
+                                ? 2
+                                : PartitionStatistics.UNKNOWN);
+        assertThat(statistics).allSatisfy(stat -> 
assertThat(stat.fileCount()).isEqualTo(1));
+
+        // A legacy split must inherit ORC, rather than accidentally taking 
the global Parquet
+        // default.
+        if (inherited.equals("orc")) {
+            FormatDataSplit base =
+                    (FormatDataSplit)
+                            splits.stream()
+                                    .filter(
+                                            split ->
+                                                    ((FormatDataSplit) split)
+                                                            .fileFormat()
+                                                            .equals("orc"))
+                                    .findFirst()
+                                    .get();
+            FormatDataSplit legacy = new FormatDataSplit(base.files(), 
base.partition());
+            assertThat(readRows(read, 
Collections.singletonList(legacy))).containsExactly("base");
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"00000_0", "part-x.parquet.gz"})
+    void testPartitionFormatTakesPrecedenceOverFileName(String fileName) 
throws Exception {
+        Path location = new Path(tempDir.toUri());
+        writeFile(location, "p", "orc", "first", "second");
+        FormatTable table =
+                table(
+                        location,
+                        "parquet",
+                        Collections.singletonList(partition("p", 
options("orc"))));
+        assertThat(
+                        table.fileIO()
+                                .rename(
+                                        new Path(location, "pt=p/data.orc"),
+                                        new Path(location, "pt=p/" + 
fileName)))
+                .isTrue();
+        ReadBuilder read = table.newReadBuilder();
+        assertThat(readRows(read, read.newScan().plan().splits()))
+                .containsExactly("first", "second");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"csv", "json"})
+    void testSplittingUsesPartitionFormat(String format) throws Exception {
+        Path tablePath = new Path(tempDir.toUri());
+        writeFile(tablePath, "override", format, "first", "second", "third", 
"fourth");
+        FormatTable table =
+                table(
+                                tablePath,
+                                "parquet",
+                                
Collections.singletonList(partition("override", options(format))))
+                        .copy(
+                                Collections.singletonMap(
+                                        
CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(), "8 b"));
+        ReadBuilder read = table.newReadBuilder();
+        List<Split> splits = read.newScan().plan().splits();
+        assertThat(splits.size()).isGreaterThan(1);
+        assertThat(readRows(read, splits)).containsExactly("first", "second", 
"third", "fourth");
+    }
+
+    @Test
+    void testCustomPathAndFormatAreIndependent() throws Exception {
+        Path tablePath = new Path(tempDir.resolve("table").toUri());
+        Path external = new Path(tempDir.resolve("external").toUri());
+        writeFile(external, "archive", "orc", "external");
+        Map<String, String> partitionOptions = options("orc");
+        partitionOptions.put(CoreOptions.PATH.key(), new Path(external, 
"pt=archive").toString());
+        FormatTable table =
+                table(
+                        tablePath,
+                        "parquet",
+                        Collections.singletonList(partition("archive", 
partitionOptions)));
+        ReadBuilder read = table.newReadBuilder();
+        assertThat(readRows(read, 
read.newScan().plan().splits())).containsExactly("external");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"static", "dynamic", "table"})
+    void testAppendRejectsDifferentFormatAndOverwriteUpdatesFormat(String 
overwriteMode)
+            throws Exception {
+        Path location = new Path(tempDir.toUri());
+        writeFile(location, "p", "orc", "old");
+        FormatTable table =
+                table(
+                                location,
+                                "parquet",
+                                Collections.singletonList(partition("p", 
options("orc"))))
+                        .copy(
+                                Collections.singletonMap(
+                                        
CoreOptions.DYNAMIC_PARTITION_OVERWRITE.key(),
+                                        
Boolean.toString(overwriteMode.equals("dynamic"))));
+        BatchWriteBuilder append = table.newBatchWriteBuilder();
+        List<CommitMessage> appendMessages;
+        try (BatchTableWrite write = append.newWrite()) {
+            write.write(
+                    GenericRow.of(
+                            BinaryString.fromString("rejected"), 
BinaryString.fromString("p")));
+            appendMessages = write.prepareCommit();
+        }
+        assertThatThrownBy(() -> append.newCommit().commit(appendMessages))
+                .hasStackTraceContaining("registered with file.format=orc");
+        assertThat(FormatTableScan.listDataFiles(table.fileIO(), new 
Path(location, "pt=p")))
+                .hasSize(1);
+        assertThat(
+                        readRows(
+                                table.newReadBuilder(),
+                                
table.newReadBuilder().newScan().plan().splits()))
+                .containsExactly("old");
+
+        AtomicReference<Map<String, String>> reported = new 
AtomicReference<>();
+        doAnswer(
+                        invocation -> {
+                            List<Map<String, String>> options = 
invocation.getArgument(4);
+                            if (options != null) {
+                                reported.set(options.get(0));
+                            }
+                            return null;
+                        })
+                .when(table.partitionManager())
+                .createPartitions(anyList(), anyBoolean(), any(), 
anyBoolean(), any());
+        BatchWriteBuilder overwrite =
+                table.newBatchWriteBuilder()
+                        .withOverwrite(
+                                overwriteMode.equals("static")
+                                        ? Collections.singletonMap("pt", "p")
+                                        : Collections.emptyMap());
+        try (BatchTableWrite write = overwrite.newWrite()) {
+            write.write(
+                    GenericRow.of(
+                            BinaryString.fromString("replacement"), 
BinaryString.fromString("p")));
+            overwrite.newCommit().commit(write.prepareCommit());
+        }
+        assertThat(reported.get()).containsEntry("file.format", "parquet");
+        FormatTable reloaded =
+                table(
+                        location,
+                        "parquet",
+                        Collections.singletonList(
+                                partition("p", 
options(reported.get().get("file.format")))));
+        assertThat(
+                        readRows(
+                                reloaded.newReadBuilder(),
+                                
reloaded.newReadBuilder().newScan().plan().splits()))
+                .containsExactly("replacement");
+    }
+
+    @Test
+    void testCrossFormatOverwriteCatalogFailureAndMetadataRepair() throws 
Exception {
+        Path location = new Path(tempDir.toUri());
+        writeFile(location, "p", "orc", "old");
+        List<Partition> registered =
+                new ArrayList<>(Collections.singletonList(partition("p", 
options("orc"))));
+        FormatTable table = table(location, "parquet", registered);
+        doThrow(new RuntimeException("catalog update failed"))
+                .doAnswer(
+                        invocation -> {
+                            List<PartitionStatistics> statistics = 
invocation.getArgument(2);
+                            List<Map<String, String>> options = 
invocation.getArgument(4);
+                            PartitionStatistics stats = statistics.get(0);
+                            registered.set(
+                                    0,
+                                    new Partition(
+                                            stats.spec(),
+                                            stats.recordCount(),
+                                            stats.fileSizeInBytes(),
+                                            stats.fileCount(),
+                                            stats.lastFileCreationTime(),
+                                            stats.totalBuckets(),
+                                            false,
+                                            null,
+                                            null,
+                                            null,
+                                            null,
+                                            options.get(0)));
+                            return null;
+                        })
+                .when(table.partitionManager())
+                .createPartitions(anyList(), anyBoolean(), any(), 
anyBoolean(), any());
+
+        BatchWriteBuilder overwrite =
+                
table.newBatchWriteBuilder().withOverwrite(Collections.singletonMap("pt", "p"));
+        List<CommitMessage> messages;
+        try (BatchTableWrite write = overwrite.newWrite()) {
+            write.write(
+                    GenericRow.of(
+                            BinaryString.fromString("replacement"), 
BinaryString.fromString("p")));
+            messages = write.prepareCommit();
+        }
+        assertThatThrownBy(() -> overwrite.newCommit().commit(messages))
+                .hasRootCauseMessage("catalog update failed");
+        Path replacement = ((TwoPhaseCommitMessage) 
messages.get(0)).getCommitter().targetPath();
+        assertThat(table.fileIO().exists(replacement)).isTrue();
+        assertThat(table.fileIO().exists(new Path(location, 
"pt=p/data.orc"))).isFalse();
+        assertThat(registered.get(0).options()).containsEntry("file.format", 
"orc");
+        ReadBuilder stale = table.newReadBuilder();
+        assertThatThrownBy(() -> readRows(stale, 
stale.newScan().plan().splits()))
+                .isInstanceOf(IOException.class);
+
+        // With the complete replacement verified and other writes stopped, 
repair only metadata.
+        Partition repair = partition("p", options("parquet"));
+        List<PartitionStatistics> statistics =
+                new FormatTablePartitionStatsCollector(table, true, 1)
+                        .collectPartitions(Collections.singletonList(repair));
+        table.partitionManager()
+                .createPartitions(
+                        Collections.singletonList(repair.spec()),
+                        true,
+                        statistics,
+                        true,
+                        Collections.singletonList(repair.options()));
+        assertThat(registered.get(0).recordCount()).isOne();
+        assertThat(registered.get(0).fileCount()).isOne();
+        assertThat(registered.get(0).fileSizeInBytes())
+                .isEqualTo(table.fileIO().getFileStatus(replacement).getLen());
+        ReadBuilder read = table.newReadBuilder();
+        assertThat(readRows(read, 
read.newScan().plan().splits())).containsExactly("replacement");
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testOnlyOverwriteReportsMatchingFormat(boolean overwrite) throws 
Exception {
+        Path location = new Path(tempDir.toUri());
+        List<Partition> existing =
+                Arrays.asList(
+                        partition("inherited", null), partition("explicit", 
options("parquet")));
+        FormatTable table = table(location, "parquet", existing);
+        AtomicReference<List<Map<String, String>>> reported = new 
AtomicReference<>();
+        doAnswer(
+                        invocation -> {
+                            reported.set(invocation.getArgument(4));
+                            return null;
+                        })
+                .when(table.partitionManager())
+                .createPartitions(anyList(), anyBoolean(), any(), 
anyBoolean(), any());
+
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        if (overwrite) {
+            builder.withOverwrite(Collections.emptyMap());
+        }
+        try (BatchTableWrite write = builder.newWrite()) {
+            for (String value : Arrays.asList("inherited", "explicit", 
"created")) {
+                write.write(
+                        GenericRow.of(
+                                BinaryString.fromString(value), 
BinaryString.fromString(value)));
+            }
+            builder.newCommit().commit(write.prepareCommit());
+        }
+        if (overwrite) {
+            assertThat(reported.get())
+                    .hasSize(3)
+                    .allSatisfy(
+                            options -> 
assertThat(options).containsEntry("file.format", "parquet"));
+        } else {
+            assertThat(reported.get()).isNull();
+        }
+        List<Partition> registered = new ArrayList<>(existing);
+        registered.add(partition("created", null));
+        ReadBuilder read = table(location, "parquet", 
registered).newReadBuilder();
+        assertThat(readRows(read, read.newScan().plan().splits()))
+                .containsExactlyInAnyOrder("inherited", "explicit", "created");
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testOverwriteReportsFormatWhenRegistryChangesAfterLookup(boolean 
explicitFormat)
+            throws Exception {
+        Path location = new Path(tempDir.toUri());
+        writeFile(location, "p", "parquet", "old");
+        List<Partition> initial =
+                Collections.singletonList(
+                        partition("p", explicitFormat ? options("parquet") : 
null));
+        FormatTable table =
+                table(location, "parquet", initial)
+                        .copy(
+                                Collections.singletonMap(
+                                        
CoreOptions.DYNAMIC_PARTITION_OVERWRITE.key(), "false"));
+        AtomicReference<String> storedFormat =
+                new AtomicReference<>(explicitFormat ? "parquet" : null);
+        when(table.partitionManager().listPartitions(anyMap(), any()))
+                .thenAnswer(
+                        invocation -> {
+                            // The lookup returns its snapshot after another 
request changes the
+                            // registry. The overwrite must report what it 
writes regardless.
+                            storedFormat.set("orc");
+                            return initial;
+                        });
+        doAnswer(
+                        invocation -> {
+                            List<Map<String, String>> reportedOptions = 
invocation.getArgument(4);
+                            if (reportedOptions != null
+                                    && 
reportedOptions.get(0).containsKey("file.format")) {
+                                
storedFormat.set(reportedOptions.get(0).get("file.format"));
+                            }
+                            return null;
+                        })
+                .when(table.partitionManager())
+                .createPartitions(anyList(), anyBoolean(), any(), 
anyBoolean(), any());
+
+        BatchWriteBuilder overwrite =
+                
table.newBatchWriteBuilder().withOverwrite(Collections.emptyMap());
+        try (BatchTableWrite write = overwrite.newWrite()) {
+            write.write(
+                    GenericRow.of(
+                            BinaryString.fromString("replacement"), 
BinaryString.fromString("p")));
+            overwrite.newCommit().commit(write.prepareCommit());
+        }
+        assertThat(storedFormat.get()).isEqualTo("parquet");
+        ReadBuilder read =
+                table(
+                                location,
+                                "parquet",
+                                Collections.singletonList(
+                                        partition("p", 
options(storedFormat.get()))))
+                        .newReadBuilder();
+        assertThat(readRows(read, 
read.newScan().plan().splits())).containsExactly("replacement");
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testTruncateDoesNotReportWriteFormat(boolean wholeTable) throws 
Exception {
+        Path location = new Path(tempDir.toUri());
+        writeFile(location, "p", "orc", "old");
+        FormatTable table =
+                table(
+                        location,
+                        "parquet",
+                        Collections.singletonList(partition("p", 
options("orc"))));
+        AtomicReference<List<Map<String, String>>> reported = new 
AtomicReference<>();
+        doAnswer(
+                        invocation -> {
+                            reported.set(invocation.getArgument(4));
+                            return null;
+                        })
+                .when(table.partitionManager())
+                .createPartitions(anyList(), anyBoolean(), any(), 
anyBoolean(), any());
+        FormatTableCommit commit = (FormatTableCommit) 
table.newBatchWriteBuilder().newCommit();
+        if (wholeTable) {
+            commit.truncateTable();
+        } else {
+            commit.truncatePartitions(
+                    Collections.singletonList(Collections.singletonMap("pt", 
"p")));
+        }
+        assertThat(reported.get())
+                .hasSize(1)
+                .allSatisfy(options -> 
assertThat(options).doesNotContainKey("file.format"));
+        assertThat(FormatTableScan.listDataFiles(table.fileIO(), new 
Path(location, "pt=p")))
+                .isEmpty();
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"static", "dynamic", "table"})
+    void testEmptyOverwriteUpdatesOnlyTargetedFormats(String overwriteMode) 
throws Exception {
+        Path location = new Path(tempDir.toUri());
+        writeFile(location, "p", "orc", "old");
+        FormatTable table =
+                table(
+                                location,
+                                "parquet",
+                                Collections.singletonList(partition("p", 
options("orc"))))
+                        .copy(
+                                Collections.singletonMap(
+                                        
CoreOptions.DYNAMIC_PARTITION_OVERWRITE.key(),
+                                        
Boolean.toString(overwriteMode.equals("dynamic"))));
+        AtomicReference<List<Map<String, String>>> reported = new 
AtomicReference<>();
+        doAnswer(
+                        invocation -> {
+                            reported.set(invocation.getArgument(4));
+                            return null;
+                        })
+                .when(table.partitionManager())
+                .createPartitions(anyList(), anyBoolean(), any(), 
anyBoolean(), any());
+        table.newBatchWriteBuilder()
+                .withOverwrite(
+                        overwriteMode.equals("static")
+                                ? Collections.singletonMap("pt", "p")
+                                : Collections.emptyMap())
+                .newCommit()
+                .commit(Collections.emptyList());
+
+        if (overwriteMode.equals("dynamic")) {
+            assertThat(reported.get()).isNull();
+            ReadBuilder read = table.newReadBuilder();
+            assertThat(readRows(read, 
read.newScan().plan().splits())).containsExactly("old");
+        } else {
+            assertThat(reported.get()).hasSize(1);
+            assertThat(reported.get().get(0)).containsEntry("file.format", 
"parquet");
+            assertThat(FormatTableScan.listDataFiles(table.fileIO(), new 
Path(location, "pt=p")))
+                    .isEmpty();
+        }
+    }
+
+    private FormatTable table(Path location, String format, List<Partition> 
partitions) {
+        FormatTablePartitionManager manager = 
mock(FormatTablePartitionManager.class);
+        when(manager.listPartitions(anyMap(), any())).thenReturn(partitions);
+        when(manager.listPartitionsByNames(anyList())).thenReturn(partitions);
+        Map<String, String> tableOptions = options(format);
+        tableOptions.put(CoreOptions.PATH.key(), location.toString());
+        return FormatTable.builder()
+                .fileIO(LocalFileIO.create())
+                .identifier(Identifier.create("db", "mixed"))
+                .rowType(TABLE_TYPE)
+                .partitionKeys(Collections.singletonList("pt"))
+                .location(location.toString())
+                .format(FormatTable.parseFormat(format))
+                .catalogContext(CatalogContext.create(new Options()))
+                .options(tableOptions)
+                .partitionManager(manager)
+                .build();
+    }
+
+    private static Partition partition(String value, Map<String, String> 
options) {
+        return new Partition(
+                Collections.singletonMap("pt", value),
+                0,
+                0,
+                0,
+                0,
+                -1,
+                false,
+                null,
+                null,
+                null,
+                null,
+                options);
+    }
+
+    private static Map<String, String> options(String format) {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.FILE_FORMAT.key(), format);
+        return options;
+    }
+
+    private static void writeFile(Path tablePath, String partition, String 
format, String... values)
+            throws Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path path = new Path(tablePath, "pt=" + partition + "/data." + format);
+        fileIO.mkdirs(path.getParent());
+        try (PositionOutputStream out = fileIO.newOutputStream(path, false)) {
+            FormatWriter writer =
+                    FileFormat.fileFormat(new CoreOptions(options(format)))
+                            .createWriterFactory(DATA_TYPE)
+                            .create(out, "none");
+            for (String value : values) {
+                
writer.addElement(GenericRow.of(BinaryString.fromString(value)));
+            }
+            writer.close();
+        }
+    }
+
+    private static List<String> readRows(ReadBuilder builder, List<Split> 
splits) throws Exception {
+        List<String> rows = new ArrayList<>();
+        for (Split split : splits) {
+            try (RecordReader<InternalRow> reader =
+                    builder.newRead().executeFilter().createReader(split)) {
+                reader.forEachRemaining(row -> 
rows.add(row.getString(0).toString()));
+            }
+        }
+        return rows;
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
index 53669467f4..c272d98aa1 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
@@ -707,6 +707,7 @@ public class FormatTableScanTest {
 
             Map<String, String> options = new HashMap<>();
             options.put(SOURCE_SPLIT_TARGET_SIZE.key(), "100b");
+            options.put(FILE_FORMAT.key(), format);
 
             FormatTable formatTable =
                     createFormatTableWithOptions(
diff --git a/paimon-core/src/test/resources/compatibility/format-data-split-v3 
b/paimon-core/src/test/resources/compatibility/format-data-split-v3
new file mode 100644
index 0000000000..5f3e8272f4
Binary files /dev/null and 
b/paimon-core/src/test/resources/compatibility/format-data-split-v3 differ
diff --git a/paimon-flink/paimon-flink-common/pom.xml 
b/paimon-flink/paimon-flink-common/pom.xml
index f0a38d8417..4478cb3a45 100644
--- a/paimon-flink/paimon-flink-common/pom.xml
+++ b/paimon-flink/paimon-flink-common/pom.xml
@@ -38,6 +38,22 @@ under the License.
     </properties>
 
     <dependencies>
+        <!-- Keep Paimon's format classes ahead of Flink SQL Gateway's ORC 
dependencies
+             in reactor builds. Published connectors include them through 
paimon-bundle. -->
+        <dependency>
+            <groupId>org.apache.paimon</groupId>
+            <artifactId>paimon-format</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+            <exclusions>
+                <!-- Use the Hadoop implementation supplied by this module's 
parent. -->
+                <exclusion>
+                    <groupId>org.apache.hadoop</groupId>
+                    <artifactId>hadoop-client-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.paimon</groupId>
             <artifactId>${paimon-flinkx-common}</artifactId>
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FormatTableITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FormatTableITCase.java
index ba8dc66aea..8a75b86f27 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FormatTableITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FormatTableITCase.java
@@ -18,12 +18,20 @@
 
 package org.apache.paimon.flink.source;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.data.Decimal;
+import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.flink.RESTCatalogITCaseBase;
+import org.apache.paimon.format.FileFormat;
+import org.apache.paimon.format.FormatWriter;
 import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
 import org.apache.paimon.fs.local.LocalFileIO;
 import org.apache.paimon.rest.RESTToken;
+import org.apache.paimon.table.FormatTable;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
 
 import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableMap;
 
@@ -32,6 +40,10 @@ import org.apache.flink.types.RowKind;
 import org.junit.jupiter.api.Test;
 
 import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
 import java.util.UUID;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -39,6 +51,79 @@ import static org.assertj.core.api.Assertions.assertThat;
 /** ITCase for format table. */
 public class FormatTableITCase extends RESTCatalogITCaseBase {
 
+    @Test
+    public void testPartitionFileFormats() throws Exception {
+        String tableName = "partition_file_formats";
+        sql(
+                "CREATE TABLE %s (id INT, pt STRING) PARTITIONED BY (pt) WITH "
+                        + 
"('type'='format-table','file.format'='parquet','metastore.partitioned-table'='true')",
+                tableName);
+        setDataToken(tableName);
+        FormatTable table =
+                (FormatTable)
+                        
flinkCatalog().catalog().getTable(Identifier.create("default", tableName));
+        String[] formats = {"orc", "parquet"};
+        String[] values = {"old", "new"};
+        for (int i = 0; i < formats.length; i++) {
+            Path file = new Path(table.location(), "pt=" + values[i] + 
"/data." + formats[i]);
+            table.fileIO().mkdirs(file.getParent());
+            try (PositionOutputStream out = 
table.fileIO().newOutputStream(file, false)) {
+                FormatWriter writer =
+                        FileFormat.fileFormat(
+                                        new CoreOptions(
+                                                Collections.singletonMap(
+                                                        "file.format", 
formats[i])))
+                                .createWriterFactory(
+                                        RowType.builder().field("id", 
DataTypes.INT()).build())
+                                .create(out, "none");
+                writer.addElement(GenericRow.of(i + 1));
+                writer.close();
+            }
+        }
+        List<Map<String, String>> specs =
+                Arrays.asList(
+                        Collections.singletonMap("pt", "old"),
+                        Collections.singletonMap("pt", "new"));
+        table.partitionManager()
+                .createPartitions(
+                        specs,
+                        false,
+                        null,
+                        false,
+                        Arrays.asList(
+                                Collections.singletonMap("file.format", "orc"),
+                                Collections.emptyMap()));
+        assertThat(sql("SELECT id, pt FROM %s", tableName))
+                .containsExactlyInAnyOrder(Row.of(1, "old"), Row.of(2, "new"));
+        assertThat(sql("SELECT id FROM %s WHERE pt='old' AND id=1", tableName))
+                .containsExactly(Row.of(1));
+        sql("INSERT INTO %s PARTITION (pt='new') VALUES (4)", tableName);
+        sql("INSERT OVERWRITE %s PARTITION (pt='new') VALUES (5)", tableName);
+        sql("INSERT INTO %s PARTITION (pt='created') VALUES (6)", tableName);
+        assertThat(
+                        table.partitionManager()
+                                .listPartitionsByNames(
+                                        Arrays.asList(
+                                                specs.get(1),
+                                                Collections.singletonMap("pt", 
"created"))))
+                .satisfiesExactly(
+                        overwritten ->
+                                assertThat(overwritten.options())
+                                        .containsEntry("file.format", 
"parquet"),
+                        appended -> 
assertThat(appended.options()).isNullOrEmpty());
+        sql("INSERT OVERWRITE %s PARTITION (pt='old') VALUES (3)", tableName);
+        assertThat(sql("SELECT id, pt FROM %s", tableName))
+                .containsExactlyInAnyOrder(
+                        Row.of(3, "old"), Row.of(5, "new"), Row.of(6, 
"created"));
+        assertThat(
+                        table.partitionManager()
+                                
.listPartitionsByNames(Collections.singletonList(specs.get(0)))
+                                .get(0)
+                                .options())
+                .containsEntry("file.format", "parquet");
+        sql("DROP TABLE %s", tableName);
+    }
+
     @Test
     public void testDiffFormat() {
         String bigDecimalStr = "10.001";
diff --git 
a/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/FormatTableMosaicReadTest.java
 
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/FormatTableMosaicReadTest.java
index 6891ba9ecf..db88d765d8 100644
--- 
a/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/FormatTableMosaicReadTest.java
+++ 
b/paimon-mosaic/src/test/java/org/apache/paimon/format/mosaic/FormatTableMosaicReadTest.java
@@ -138,6 +138,37 @@ class FormatTableMosaicReadTest {
         assertThat(splits).isEmpty();
     }
 
+    @Test
+    void testMosaicSplitOverridesTableFormat() throws Exception {
+        RowType rowType =
+                RowType.builder()
+                        .field("a", DataTypes.STRING())
+                        .field("dt", DataTypes.STRING())
+                        .build();
+        FormatTable table =
+                buildFormatTable(rowType, Collections.singletonList("dt"), new 
HashMap<>());
+        writeAll(
+                table,
+                Collections.singletonList(
+                        GenericRow.of(
+                                BinaryString.fromString("value"), 
BinaryString.fromString("p"))));
+        List<Split> splits = table.newReadBuilder().newScan().plan().splits();
+        FormatTable readerTable = 
table.copy(Collections.singletonMap("file.format", "parquet"));
+        List<String> values = new ArrayList<>();
+        for (Split split : splits) {
+            try (RecordReader<InternalRow> reader =
+                    
readerTable.newReadBuilder().newRead().createReader(split)) {
+                reader.forEachRemaining(
+                        row ->
+                                values.add(
+                                        row.getString(0).toString()
+                                                + "/"
+                                                + 
row.getString(1).toString()));
+            }
+        }
+        assertThat(values).containsExactly("value/p");
+    }
+
     @Test
     void testInvalidCompressionPropagatedFromWriter() {
         RowType rowType = RowType.builder().field("a", 
DataTypes.STRING()).build();
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonAnalyzeFormatTablePartitionsCommand.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonAnalyzeFormatTablePartitionsCommand.scala
index d593821127..24b06d6309 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonAnalyzeFormatTablePartitionsCommand.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonAnalyzeFormatTablePartitionsCommand.scala
@@ -20,7 +20,7 @@ package org.apache.paimon.spark.commands
 
 import org.apache.paimon.CoreOptions
 import org.apache.paimon.fs.Path
-import org.apache.paimon.partition.PartitionStatistics
+import org.apache.paimon.partition.{Partition, PartitionStatistics}
 import org.apache.paimon.spark.catalyst.analysis.PaimonResolvePartitionSpec
 import org.apache.paimon.spark.format.PaimonFormatTable
 import org.apache.paimon.spark.leafnode.PaimonLeafRunnableCommand
@@ -34,7 +34,7 @@ import 
org.apache.spark.sql.catalyst.expressions.GenericInternalRow
 import org.apache.spark.sql.types.{StringType, StructField, StructType}
 import org.apache.spark.unsafe.types.UTF8String
 
-import java.util.{Collections, List => JList, Map => JMap, Objects}
+import java.util.{Collections, List => JList, Objects}
 
 import scala.collection.JavaConverters._
 import scala.collection.immutable.ListMap
@@ -107,9 +107,9 @@ case class PaimonAnalyzeFormatTablePartitionsCommand(
         if (noScan) {
           // One listing request per partition is all NOSCAN reports, cheap 
enough to take here.
           new FormatTablePartitionStatsCollector(v2Table.table, false, 
parallelism)
-            .collect(partitions.asJava)
+            .collectPartitions(registeredPartitions.asJava)
         } else {
-          measureOnExecutors(sparkSession, partitions, parallelism)
+          measureOnExecutors(sparkSession, registeredPartitions, parallelism)
         }
       v2Table.partitionManager
         .createPartitions(partitions.asJava, true, statistics, true, null)
@@ -128,7 +128,7 @@ case class PaimonAnalyzeFormatTablePartitionsCommand(
    */
   private def measureOnExecutors(
       sparkSession: SparkSession,
-      partitions: List[JMap[String, String]],
+      partitions: List[Partition],
       parallelism: Int): JList[PartitionStatistics] = {
     val effectiveParallelism = math.max(1, parallelism)
     val tasks = math.min(effectiveParallelism, partitions.size)
@@ -147,7 +147,7 @@ case class PaimonAnalyzeFormatTablePartitionsCommand(
             work
               .map(_._2)
               .iterator
-              .zip(collector.collect(work.map(_._1).asJava).asScala.iterator)
+              
.zip(collector.collectPartitions(work.map(_._1).asJava).asScala.iterator)
           }
       }
       .collect()
diff --git 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
index b0430f853c..7ec61aa024 100644
--- 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
+++ 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
@@ -878,7 +878,13 @@ class FormatTablePartitionRepairTest {
 
         @Override
         public List<Partition> listPartitionsByNames(List<Map<String, String>> 
partitions) {
-            throw new UnsupportedOperationException();
+            List<Partition> result = new ArrayList<>(partitions.size());
+            for (Map<String, String> spec : partitions) {
+                if (registered.contains(spec)) {
+                    result.add(partition(spec));
+                }
+            }
+            return result;
         }
 
         @Override
@@ -887,22 +893,25 @@ class FormatTablePartitionRepairTest {
             requestedPrefixes.add(prefix);
             List<Partition> partitions = new ArrayList<>(registered.size());
             for (Map<String, String> spec : registered) {
-                partitions.add(
-                        new Partition(
-                                spec,
-                                0L,
-                                0L,
-                                0L,
-                                0L,
-                                0,
-                                false,
-                                null,
-                                null,
-                                null,
-                                null,
-                                partitionOptions.get(spec)));
+                partitions.add(partition(spec));
             }
             return partitions;
         }
+
+        private Partition partition(Map<String, String> spec) {
+            return new Partition(
+                    spec,
+                    0L,
+                    0L,
+                    0L,
+                    0L,
+                    0,
+                    false,
+                    null,
+                    null,
+                    null,
+                    null,
+                    partitionOptions.get(spec));
+        }
     }
 }
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/table/PaimonFormatTableTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/table/PaimonFormatTableTest.scala
index 2668929b2f..57aa2baf7a 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/table/PaimonFormatTableTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/table/PaimonFormatTableTest.scala
@@ -28,8 +28,62 @@ import org.apache.spark.sql.Row
 import org.apache.spark.sql.connector.catalog.TableCapability
 import org.apache.spark.sql.connector.catalog.TableCatalog
 
+import scala.collection.JavaConverters._
+
 class PaimonFormatTableTest extends PaimonSparkTestWithRestCatalogBase {
 
+  test("PaimonFormatTable: partition file formats survive reads statistics and 
overwrite") {
+    val tableName = "partition_file_formats"
+    withTable(tableName) {
+      sql(s"CREATE TABLE $tableName (id INT, pt STRING) USING parquet 
PARTITIONED BY (pt) " +
+        "TBLPROPERTIES ('metastore.partitioned-table'='true', 
'format-table.implementation'='paimon')")
+      val table =
+        paimonCatalog.getTable(Identifier.create("test_db", 
tableName)).asInstanceOf[FormatTable]
+      spark
+        .sql("SELECT 1 AS id")
+        .write
+        .format("orc")
+        .save(new Path(table.location(), "pt=old").toString)
+      spark.sql("SELECT 2 AS id").write.parquet(new Path(table.location(), 
"pt=new").toString)
+      val specs = Seq(Map("pt" -> "old").asJava, Map("pt" -> 
"new").asJava).asJava
+      table
+        .partitionManager()
+        .createPartitions(
+          specs,
+          false,
+          null,
+          false,
+          Seq(Map("file.format" -> "orc").asJava, Map.empty[String, 
String].asJava).asJava)
+      checkAnswer(sql(s"SELECT id, pt FROM $tableName"), Seq(Row(1, "old"), 
Row(2, "new")))
+      checkAnswer(sql(s"SELECT id FROM $tableName WHERE pt='old' AND id=1"), 
Seq(Row(1)))
+      sql(s"ANALYZE TABLE $tableName COMPUTE STATISTICS")
+      assert(
+        
table.partitionManager().listPartitionsByNames(specs).asScala.forall(_.recordCount()
 == 1))
+      sql(s"INSERT INTO $tableName PARTITION (pt='new') VALUES (4)")
+      sql(s"INSERT OVERWRITE $tableName PARTITION (pt='new') VALUES (5)")
+      sql(s"INSERT INTO $tableName PARTITION (pt='created') VALUES (6)")
+      val writtenSpecs = Seq(specs.get(1), Map("pt" -> 
"created").asJava).asJava
+      val writtenPartitions =
+        table
+          .partitionManager()
+          .listPartitionsByNames(writtenSpecs)
+      assert(writtenPartitions.size() == 2)
+      assert(writtenPartitions.get(0).options().get("file.format") == 
"parquet")
+      assert(Option(writtenPartitions.get(1).options()).forall(_.isEmpty))
+      sql(s"INSERT OVERWRITE $tableName PARTITION (pt='old') VALUES (3)")
+      checkAnswer(
+        sql(s"SELECT id, pt FROM $tableName"),
+        Seq(Row(3, "old"), Row(5, "new"), Row(6, "created")))
+      assert(
+        table
+          .partitionManager()
+          .listPartitionsByNames(Seq(specs.get(0)).asJava)
+          .get(0)
+          .options()
+          .get("file.format") == "parquet")
+    }
+  }
+
   override protected def beforeEach(): Unit = {
     super.beforeEach()
     sql("USE paimon")

Reply via email to