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

wgtmac pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/parquet-java.git


The following commit(s) were added to refs/heads/master by this push:
     new 8e4f57163 PARQUET-3459: Per column compression (#3526)
8e4f57163 is described below

commit 8e4f571630276201d6db97e28c635fcce7f44270
Author: mengna-lin <[email protected]>
AuthorDate: Mon Jul 13 19:34:50 2026 -0700

    PARQUET-3459: Per column compression (#3526)
---
 .../apache/parquet/column/ParquetProperties.java   |  79 ++++-
 .../parquet/column/TestParquetProperties.java      | 141 ++++++++
 .../compression/CompressionCodecFactory.java       |  24 ++
 .../org/apache/parquet/hadoop/CodecFactory.java    | 150 +++++++-
 .../parquet/hadoop/ColumnChunkPageWriteStore.java  | 149 +++++++-
 .../apache/parquet/hadoop/DirectCodecFactory.java  |  30 +-
 .../hadoop/InternalParquetRecordWriter.java        |  26 +-
 .../apache/parquet/hadoop/ParquetOutputFormat.java |  17 +
 .../apache/parquet/hadoop/ParquetRecordWriter.java |   6 +-
 .../org/apache/parquet/hadoop/ParquetWriter.java   |  36 +-
 .../hadoop/TestColumnChunkPageWriteStore.java      | 133 +++++++
 .../parquet/hadoop/TestDirectCodecFactory.java     | 159 +++++++++
 .../apache/parquet/hadoop/TestParquetWriter.java   | 388 +++++++++++++++++++++
 13 files changed, 1293 insertions(+), 45 deletions(-)

diff --git 
a/parquet-column/src/main/java/org/apache/parquet/column/ParquetProperties.java 
b/parquet-column/src/main/java/org/apache/parquet/column/ParquetProperties.java
index f29214b45..8fe45e01e 100644
--- 
a/parquet-column/src/main/java/org/apache/parquet/column/ParquetProperties.java
+++ 
b/parquet-column/src/main/java/org/apache/parquet/column/ParquetProperties.java
@@ -39,6 +39,7 @@ import 
org.apache.parquet.column.values.factory.DefaultValuesWriterFactory;
 import org.apache.parquet.column.values.factory.ValuesWriterFactory;
 import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridEncoder;
 import 
org.apache.parquet.column.values.rle.RunLengthBitPackingHybridValuesWriter;
+import org.apache.parquet.hadoop.metadata.CompressionCodecName;
 import org.apache.parquet.schema.MessageType;
 
 /**
@@ -135,6 +136,8 @@ public class ParquetProperties {
   private final Map<String, String> extraMetaData;
   private final ColumnProperty<Boolean> statistics;
   private final ColumnProperty<Boolean> sizeStatistics;
+  private final ColumnProperty<CompressionCodecName> columnCodecs;
+  private final ColumnProperty<Integer> columnCompressionLevels;
 
   private ParquetProperties(Builder builder) {
     this.pageSizeThreshold = builder.pageSize;
@@ -167,6 +170,8 @@ public class ParquetProperties {
     this.extraMetaData = builder.extraMetaData;
     this.statistics = builder.statistics.build();
     this.sizeStatistics = builder.sizeStatistics.build();
+    this.columnCodecs = builder.columnCodecs.build();
+    this.columnCompressionLevels = builder.columnCompressionLevels.build();
   }
 
   public static Builder builder() {
@@ -370,9 +375,31 @@ public class ParquetProperties {
     return sizeStatisticsEnabled;
   }
 
+  /**
+   * Returns the compression codec configured for the given column, or {@code 
null} if no
+   * column-specific codec has been set (the caller should fall back to the 
job-level codec).
+   *
+   * @param column the column descriptor
+   * @return the per-column codec, or {@code null} if not set
+   */
+  public CompressionCodecName getColumnCodec(ColumnDescriptor column) {
+    return columnCodecs.getValue(column);
+  }
+
+  /**
+   * Returns the compression level configured for the given column, or {@code 
null} if no
+   * column-specific level has been set.
+   *
+   * @param column the column descriptor
+   * @return the per-column compression level, or {@code null} if not set
+   */
+  public Integer getColumnCompressionLevel(ColumnDescriptor column) {
+    return columnCompressionLevels.getValue(column);
+  }
+
   @Override
   public String toString() {
-    return "Parquet page size to " + getPageSizeThreshold() + '\n'
+    String result = "Parquet page size to " + getPageSizeThreshold() + '\n'
         + "Parquet dictionary page size to " + 
getDictionaryPageSizeThreshold() + '\n'
         + "Dictionary is " + dictionaryEnabled + '\n'
         + "Writer version is: " + getWriterVersion() + '\n'
@@ -389,6 +416,18 @@ public class ParquetProperties {
         + "Writing page checksums is: " + (getPageWriteChecksumEnabled() ? 
"on" : "off") + '\n'
         + "Statistics enabled: " + statisticsEnabled + '\n'
         + "Size statistics enabled: " + sizeStatisticsEnabled;
+    String perColumn = "";
+    if 
(!columnCodecs.toString().equals(Objects.toString(columnCodecs.getDefaultValue())))
 {
+      perColumn = "Per-column codecs: " + columnCodecs;
+    }
+    if 
(!columnCompressionLevels.toString().equals(Objects.toString(columnCompressionLevels.getDefaultValue())))
 {
+      perColumn += perColumn.isEmpty() ? "Per-column compression levels: " : 
", compression levels: ";
+      perColumn += columnCompressionLevels;
+    }
+    if (!perColumn.isEmpty()) {
+      result += '\n' + perColumn;
+    }
+    return result;
   }
 
   public static class Builder {
@@ -419,6 +458,8 @@ public class ParquetProperties {
     private Map<String, String> extraMetaData = new HashMap<>();
     private final ColumnProperty.Builder<Boolean> statistics;
     private final ColumnProperty.Builder<Boolean> sizeStatistics;
+    private final ColumnProperty.Builder<CompressionCodecName> columnCodecs;
+    private final ColumnProperty.Builder<Integer> columnCompressionLevels;
 
     private Builder() {
       enableDict = 
ColumnProperty.<Boolean>builder().withDefaultValue(DEFAULT_IS_DICTIONARY_ENABLED);
@@ -436,6 +477,8 @@ public class ParquetProperties {
           
ColumnProperty.<Integer>builder().withDefaultValue(DEFAULT_BLOOM_FILTER_CANDIDATES_NUMBER);
       statistics = 
ColumnProperty.<Boolean>builder().withDefaultValue(DEFAULT_STATISTICS_ENABLED);
       sizeStatistics = 
ColumnProperty.<Boolean>builder().withDefaultValue(DEFAULT_SIZE_STATISTICS_ENABLED);
+      columnCodecs = 
ColumnProperty.<CompressionCodecName>builder().withDefaultValue(null);
+      columnCompressionLevels = 
ColumnProperty.<Integer>builder().withDefaultValue(null);
     }
 
     private Builder(ParquetProperties toCopy) {
@@ -448,7 +491,11 @@ public class ParquetProperties {
       this.estimateNextSizeCheck = toCopy.estimateNextSizeCheck;
       this.valuesWriterFactory = toCopy.valuesWriterFactory;
       this.allocator = toCopy.allocator;
+      this.pageValueCountThreshold = toCopy.pageValueCountThreshold;
       this.pageRowCountLimit = toCopy.pageRowCountLimit;
+      this.rowGroupRowCountLimit = toCopy.rowGroupRowCountLimit;
+      this.columnIndexTruncateLength = toCopy.columnIndexTruncateLength;
+      this.statisticsTruncateLength = toCopy.statisticsTruncateLength;
       this.pageWriteChecksumEnabled = toCopy.pageWriteChecksumEnabled;
       this.bloomFilterNDVs = ColumnProperty.builder(toCopy.bloomFilterNDVs);
       this.bloomFilterFPPs = ColumnProperty.builder(toCopy.bloomFilterFPPs);
@@ -459,7 +506,11 @@ public class ParquetProperties {
       this.byteStreamSplitEnabled = 
ColumnProperty.builder(toCopy.byteStreamSplitEnabled);
       this.extraMetaData = toCopy.extraMetaData;
       this.statistics = ColumnProperty.builder(toCopy.statistics);
+      this.statisticsEnabled = toCopy.statisticsEnabled;
       this.sizeStatistics = ColumnProperty.builder(toCopy.sizeStatistics);
+      this.sizeStatisticsEnabled = toCopy.sizeStatisticsEnabled;
+      this.columnCodecs = ColumnProperty.builder(toCopy.columnCodecs);
+      this.columnCompressionLevels = 
ColumnProperty.builder(toCopy.columnCompressionLevels);
     }
 
     /**
@@ -756,6 +807,32 @@ public class ParquetProperties {
       return this;
     }
 
+    /**
+     * Set the compression codec for the specified column.
+     *
+     * @param columnPath the path of the column (dot-string)
+     * @param codec      the compression codec to use for this column
+     * @return this builder for method chaining
+     */
+    public Builder withCompressionCodec(String columnPath, 
CompressionCodecName codec) {
+      this.columnCodecs.withValue(columnPath, Objects.requireNonNull(codec, 
"codec cannot be null"));
+      return this;
+    }
+
+    /**
+     * Set the compression level for the specified column.
+     * The valid range is codec-specific (e.g. ZSTD: negative levels trade 
ratio for speed, up to 22 for best compression (default 3), GZIP: 0–9 or -1 
(default 6), BROTLI: 0–11 (default 1)).
+     * Pass {@code null} to unset a previously configured level for that 
column.
+     *
+     * @param columnPath the path of the column (dot-string)
+     * @param level      the compression level, or {@code null} to unset
+     * @return this builder for method chaining
+     */
+    public Builder withCompressionLevel(String columnPath, Integer level) {
+      this.columnCompressionLevels.withValue(columnPath, level);
+      return this;
+    }
+
     public ParquetProperties build() {
       ParquetProperties properties = new ParquetProperties(this);
       // we pass a constructed but uninitialized factory to ParquetProperties 
above as currently
diff --git 
a/parquet-column/src/test/java/org/apache/parquet/column/TestParquetProperties.java
 
b/parquet-column/src/test/java/org/apache/parquet/column/TestParquetProperties.java
new file mode 100644
index 000000000..8456e42f4
--- /dev/null
+++ 
b/parquet-column/src/test/java/org/apache/parquet/column/TestParquetProperties.java
@@ -0,0 +1,141 @@
+/*
+ * 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.parquet.column;
+
+import static org.apache.parquet.hadoop.metadata.CompressionCodecName.GZIP;
+import static org.apache.parquet.hadoop.metadata.CompressionCodecName.SNAPPY;
+import static org.apache.parquet.hadoop.metadata.CompressionCodecName.ZSTD;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.MessageTypeParser;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TestParquetProperties {
+
+  private static final MessageType SCHEMA = MessageTypeParser.parseMessageType(
+      "message test { required binary col_a; required int32 col_b; required 
double col_c; }");
+
+  private ColumnDescriptor colA;
+  private ColumnDescriptor colB;
+  private ColumnDescriptor colC;
+
+  @Before
+  public void setUp() {
+    colA = SCHEMA.getColumns().get(0);
+    colB = SCHEMA.getColumns().get(1);
+    colC = SCHEMA.getColumns().get(2);
+  }
+
+  @Test
+  public void columnCodec_notSet_returnsNull() {
+    ParquetProperties props = ParquetProperties.builder().build();
+    assertNull(props.getColumnCodec(colA));
+  }
+
+  @Test
+  public void columnLevel_notSet_returnsNull() {
+    ParquetProperties props = ParquetProperties.builder().build();
+    assertNull(props.getColumnCompressionLevel(colA));
+  }
+
+  @Test
+  public void columnCodec_setForColumn_returnsConfiguredCodec() {
+    ParquetProperties props =
+        ParquetProperties.builder().withCompressionCodec("col_a", 
ZSTD).build();
+    assertEquals(ZSTD, props.getColumnCodec(colA));
+  }
+
+  @Test
+  public void columnCodec_setMultipleTimes_lastValueWins() {
+    ParquetProperties props = ParquetProperties.builder()
+        .withCompressionCodec("col_a", ZSTD)
+        .withCompressionCodec("col_a", SNAPPY)
+        .build();
+    assertEquals(SNAPPY, props.getColumnCodec(colA));
+  }
+
+  @Test
+  public void columnCodec_otherColumnsUnaffected() {
+    ParquetProperties props =
+        ParquetProperties.builder().withCompressionCodec("col_a", 
ZSTD).build();
+    assertNull(props.getColumnCodec(colB));
+    assertNull(props.getColumnCodec(colC));
+  }
+
+  @Test
+  public void columnLevel_setForColumn_returnsConfiguredLevel() {
+    ParquetProperties props =
+        ParquetProperties.builder().withCompressionLevel("col_a", 10).build();
+    assertEquals(Integer.valueOf(10), props.getColumnCompressionLevel(colA));
+  }
+
+  @Test
+  public void columnLevel_otherColumnsUnaffected() {
+    ParquetProperties props =
+        ParquetProperties.builder().withCompressionLevel("col_a", 10).build();
+    assertNull(props.getColumnCompressionLevel(colB));
+  }
+
+  @Test
+  public void columnCodecAndLevel_multipleColumns_eachGetsOwn() {
+    ParquetProperties props = ParquetProperties.builder()
+        .withCompressionCodec("col_a", ZSTD)
+        .withCompressionLevel("col_a", 10)
+        .withCompressionCodec("col_b", SNAPPY)
+        .withCompressionCodec("col_c", GZIP)
+        .withCompressionLevel("col_c", 5)
+        .build();
+
+    assertEquals(ZSTD, props.getColumnCodec(colA));
+    assertEquals(Integer.valueOf(10), props.getColumnCompressionLevel(colA));
+
+    assertEquals(SNAPPY, props.getColumnCodec(colB));
+    assertNull(props.getColumnCompressionLevel(colB));
+
+    assertEquals(GZIP, props.getColumnCodec(colC));
+    assertEquals(Integer.valueOf(5), props.getColumnCompressionLevel(colC));
+  }
+
+  @Test
+  public void withCompressionCodec_nullCodec_throwsNullPointerException() {
+    assertThrows(
+        NullPointerException.class, () -> 
ParquetProperties.builder().withCompressionCodec("col_a", null));
+  }
+
+  @Test
+  public void copyBuilder_preservesColumnCodecAndLevel() {
+    ParquetProperties original = ParquetProperties.builder()
+        .withCompressionCodec("col_a", ZSTD)
+        .withCompressionLevel("col_a", 7)
+        .withCompressionCodec("col_b", SNAPPY)
+        .build();
+
+    ParquetProperties copy = ParquetProperties.copy(original).build();
+
+    assertEquals(ZSTD, copy.getColumnCodec(colA));
+    assertEquals(Integer.valueOf(7), copy.getColumnCompressionLevel(colA));
+    assertEquals(SNAPPY, copy.getColumnCodec(colB));
+    assertNull(copy.getColumnCompressionLevel(colB));
+    assertNull(copy.getColumnCodec(colC));
+  }
+}
diff --git 
a/parquet-common/src/main/java/org/apache/parquet/compression/CompressionCodecFactory.java
 
b/parquet-common/src/main/java/org/apache/parquet/compression/CompressionCodecFactory.java
index 561dcb899..bc5a26ab8 100644
--- 
a/parquet-common/src/main/java/org/apache/parquet/compression/CompressionCodecFactory.java
+++ 
b/parquet-common/src/main/java/org/apache/parquet/compression/CompressionCodecFactory.java
@@ -46,6 +46,30 @@ public interface CompressionCodecFactory {
    */
   BytesInputCompressor getCompressor(CompressionCodecName codecName);
 
+  /**
+   * Returns a {@link BytesInputCompressor} instance for the specified codec 
name and compression level.
+   * <p>
+   * The compression level controls the trade-off between compression speed 
and ratio. The valid range
+   * and meaning of the level is codec-specific:
+   * <ul>
+   *   <li>ZSTD: negative levels trade compression ratio for higher speed, up 
to 22 for best compression, default 3; the exact bounds are defined by the zstd 
library</li>
+   *   <li>GZIP: 1 (fastest) to 9 (best compression), default 6</li>
+   *   <li>BROTLI: 0 (fastest) to 11 (best compression), default 1</li>
+   * </ul>
+   * Implementations that do not support compression levels should ignore the 
{@code level} parameter
+   * and delegate to {@link #getCompressor(CompressionCodecName)}.
+   * <p>
+   * The compressor is not thread-safe, so one instance for each working 
thread is required.
+   *
+   * @param codecName the codec name which the compressor instance is to be 
returned
+   * @param level     the compression level; codec-specific, ignored if the 
codec does not support levels
+   * @return the compressor instance for the specified codec name and level
+   * @see BytesInputCompressor#release()
+   */
+  default BytesInputCompressor getCompressor(CompressionCodecName codecName, 
int level) {
+    return getCompressor(codecName);
+  }
+
   /**
    * Returns a {@link BytesInputDecompressor} instance for the specified codec 
name to be used for decompressing page
    * data.
diff --git 
a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/CodecFactory.java 
b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/CodecFactory.java
index 98b49835a..c9391201f 100644
--- a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/CodecFactory.java
+++ b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/CodecFactory.java
@@ -18,6 +18,7 @@
  */
 package org.apache.parquet.hadoop;
 
+import com.github.luben.zstd.Zstd;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -32,6 +33,7 @@ import org.apache.hadoop.io.compress.CompressionCodec;
 import org.apache.hadoop.io.compress.CompressionOutputStream;
 import org.apache.hadoop.io.compress.Compressor;
 import org.apache.hadoop.io.compress.Decompressor;
+import org.apache.hadoop.io.compress.zlib.ZlibCompressor;
 import org.apache.hadoop.util.ReflectionUtils;
 import org.apache.parquet.Preconditions;
 import org.apache.parquet.bytes.ByteBufferAllocator;
@@ -43,13 +45,20 @@ import org.apache.parquet.hadoop.codec.Lz4RawCodec;
 import org.apache.parquet.hadoop.codec.ZstandardCodec;
 import org.apache.parquet.hadoop.metadata.CompressionCodecName;
 import org.apache.parquet.hadoop.util.ConfigurationUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class CodecFactory implements CompressionCodecFactory {
 
+  private static final Logger LOG = 
LoggerFactory.getLogger(CodecFactory.class);
+
   protected static final Map<String, CompressionCodec> CODEC_BY_NAME =
       Collections.synchronizedMap(new HashMap<String, CompressionCodec>());
 
-  private final Map<CompressionCodecName, BytesCompressor> compressors = new 
HashMap<>();
+  static final String GZIP_COMPRESS_LEVEL = "zlib.compress.level";
+  static final String BROTLI_COMPRESS_QUALITY = "compression.brotli.quality";
+
+  private final Map<String, BytesCompressor> compressors = new HashMap<>();
   private final Map<CompressionCodecName, BytesDecompressor> decompressors = 
new HashMap<>();
 
   protected final ParquetConfiguration conf;
@@ -252,10 +261,22 @@ public class CodecFactory implements 
CompressionCodecFactory {
 
   @Override
   public BytesCompressor getCompressor(CompressionCodecName codecName) {
-    BytesCompressor comp = compressors.get(codecName);
+    String key = cacheKey(codecName);
+    BytesCompressor comp = compressors.get(key);
     if (comp == null) {
       comp = createCompressor(codecName);
-      compressors.put(codecName, comp);
+      compressors.put(key, comp);
+    }
+    return comp;
+  }
+
+  @Override
+  public BytesCompressor getCompressor(CompressionCodecName codecName, int 
level) {
+    String key = cacheKey(codecName, level);
+    BytesCompressor comp = compressors.get(key);
+    if (comp == null) {
+      comp = createCompressorAtLevel(codecName, level);
+      compressors.put(key, comp);
     }
     return comp;
   }
@@ -271,8 +292,7 @@ public class CodecFactory implements 
CompressionCodecFactory {
   }
 
   protected BytesCompressor createCompressor(CompressionCodecName codecName) {
-    CompressionCodec codec = getCodec(codecName);
-    return codec == null ? NO_OP_COMPRESSOR : new 
HeapBytesCompressor(codecName, codec);
+    return compressorForCodec(codecName, getCodec(codecName));
   }
 
   protected BytesDecompressor createDecompressor(CompressionCodecName 
codecName) {
@@ -280,6 +300,117 @@ public class CodecFactory implements 
CompressionCodecFactory {
     return codec == null ? NO_OP_DECOMPRESSOR : new 
HeapBytesDecompressor(codec);
   }
 
+  protected BytesCompressor createCompressorAtLevel(CompressionCodecName 
codecName, int level) {
+    return compressorForCodec(codecName, getCodecAtLevel(codecName, level));
+  }
+
+  private BytesCompressor compressorForCodec(CompressionCodecName codecName, 
CompressionCodec codec) {
+    return codec == null ? NO_OP_COMPRESSOR : new 
HeapBytesCompressor(codecName, codec);
+  }
+
+  static void validateZstdLevel(int level) {
+    if (level < Zstd.minCompressionLevel() || level > 
Zstd.maxCompressionLevel()) {
+      throw new BadConfigurationException(
+          "Unsupported ZSTD compression level: " + level + ". Valid range is " 
+ Zstd.minCompressionLevel()
+              + " (fastest) to " + Zstd.maxCompressionLevel() + " (best 
compression).");
+    }
+  }
+
+  private static void validateBrotliLevel(int level) {
+    if (level < 0 || level > 11) {
+      throw new BadConfigurationException("Unsupported Brotli compression 
level: " + level
+          + ". Valid range is 0 (fastest) to 11 (best compression).");
+    }
+  }
+
+  private static void validateGzipLevel(int level) {
+    if (level != -1 && (level < 0 || level > 9)) {
+      throw new BadConfigurationException("Unsupported GZIP compression level: 
" + level
+          + ". Valid range is 0 (no compression) to 9 (best compression), or 
-1 for default.");
+    }
+  }
+
+  private static ZlibCompressor.CompressionLevel zlibCompressionLevel(int 
level) {
+    switch (level) {
+      case -1:
+        return ZlibCompressor.CompressionLevel.DEFAULT_COMPRESSION;
+      case 0:
+        return ZlibCompressor.CompressionLevel.NO_COMPRESSION;
+      case 1:
+        return ZlibCompressor.CompressionLevel.BEST_SPEED;
+      case 2:
+        return ZlibCompressor.CompressionLevel.TWO;
+      case 3:
+        return ZlibCompressor.CompressionLevel.THREE;
+      case 4:
+        return ZlibCompressor.CompressionLevel.FOUR;
+      case 5:
+        return ZlibCompressor.CompressionLevel.FIVE;
+      case 6:
+        return ZlibCompressor.CompressionLevel.SIX;
+      case 7:
+        return ZlibCompressor.CompressionLevel.SEVEN;
+      case 8:
+        return ZlibCompressor.CompressionLevel.EIGHT;
+      case 9:
+        return ZlibCompressor.CompressionLevel.BEST_COMPRESSION;
+      default:
+        throw new BadConfigurationException("Unsupported GZIP compression 
level: " + level
+            + ". Valid range is 0 (no compression) to 9 (best compression), or 
-1 for default.");
+    }
+  }
+
+  /**
+   * Returns a {@link CompressionCodec} instance configured at the specified 
compression level.
+   * A level-specific {@link Configuration} snapshot is built so that the 
codec is initialized
+   * with the requested level rather than the global configuration value.
+   * For codecs that do not support levels the method falls back to {@link 
#getCodec(CompressionCodecName)}.
+   */
+  private CompressionCodec getCodecAtLevel(CompressionCodecName codecName, int 
level) {
+    String codecClassName = codecName.getHadoopCompressionCodecClassName();
+    if (codecClassName == null) {
+      return null;
+    }
+    String key = cacheKey(codecName, level);
+    CompressionCodec codec = CODEC_BY_NAME.get(key);
+    if (codec != null) {
+      return codec;
+    }
+    // Build a Configuration snapshot with the level explicitly set for this 
codec.
+    Configuration levelConf = new 
Configuration(ConfigurationUtil.createHadoopConfiguration(conf));
+    switch (codecName) {
+      case ZSTD:
+        validateZstdLevel(level);
+        levelConf.setInt(ZstandardCodec.PARQUET_COMPRESS_ZSTD_LEVEL, level);
+        break;
+      case GZIP:
+        validateGzipLevel(level);
+        levelConf.setEnum(GZIP_COMPRESS_LEVEL, zlibCompressionLevel(level));
+        break;
+      case BROTLI:
+        validateBrotliLevel(level);
+        levelConf.setInt(BROTLI_COMPRESS_QUALITY, level);
+        break;
+      default:
+        // Codec does not support levels; fall back to the default codec 
instance.
+        LOG.warn("Compression level {} is not supported for codec {} and will 
be ignored.", level, codecName);
+        return getCodec(codecName);
+    }
+    try {
+      Class<?> codecClass;
+      try {
+        codecClass = Class.forName(codecClassName);
+      } catch (ClassNotFoundException e) {
+        codecClass = new 
Configuration(false).getClassLoader().loadClass(codecClassName);
+      }
+      codec = (CompressionCodec) ReflectionUtils.newInstance(codecClass, 
levelConf);
+      CODEC_BY_NAME.put(key, codec);
+      return codec;
+    } catch (ClassNotFoundException e) {
+      throw new BadConfigurationException("Class " + codecClassName + " was 
not found", e);
+    }
+  }
+
   /**
    * @param codecName the requested codec
    * @return the corresponding hadoop codec. null if UNCOMPRESSED
@@ -316,10 +447,10 @@ public class CodecFactory implements 
CompressionCodecFactory {
     String level = null;
     switch (codecName) {
       case GZIP:
-        level = conf.get("zlib.compress.level");
+        level = conf.get(GZIP_COMPRESS_LEVEL);
         break;
       case BROTLI:
-        level = conf.get("compression.brotli.quality");
+        level = conf.get(BROTLI_COMPRESS_QUALITY);
         break;
       case ZSTD:
         level = conf.get("parquet.compression.codec.zstd.level");
@@ -331,6 +462,11 @@ public class CodecFactory implements 
CompressionCodecFactory {
     return level == null ? codecClass : codecClass + ":" + level;
   }
 
+  private String cacheKey(CompressionCodecName codecName, int level) {
+    String codecClass = codecName.getHadoopCompressionCodecClassName();
+    return (codecClass == null ? codecName.name() : codecClass) + ":" + level;
+  }
+
   @Override
   public void release() {
     for (BytesCompressor compressor : compressors.values()) {
diff --git 
a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ColumnChunkPageWriteStore.java
 
b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ColumnChunkPageWriteStore.java
index c0cf216cc..69c28b2cd 100644
--- 
a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ColumnChunkPageWriteStore.java
+++ 
b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ColumnChunkPageWriteStore.java
@@ -25,8 +25,10 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.Function;
 import java.util.zip.CRC32;
 import org.apache.parquet.bytes.ByteBufferAllocator;
 import org.apache.parquet.bytes.ByteBufferReleaser;
@@ -44,6 +46,7 @@ import 
org.apache.parquet.column.statistics.geospatial.GeospatialStatistics;
 import org.apache.parquet.column.values.bloomfilter.BloomFilter;
 import org.apache.parquet.column.values.bloomfilter.BloomFilterWriteStore;
 import org.apache.parquet.column.values.bloomfilter.BloomFilterWriter;
+import org.apache.parquet.compression.CompressionCodecFactory;
 import 
org.apache.parquet.compression.CompressionCodecFactory.BytesInputCompressor;
 import org.apache.parquet.crypto.AesCipher;
 import org.apache.parquet.crypto.InternalColumnEncryptionSetup;
@@ -53,6 +56,7 @@ import org.apache.parquet.format.BlockCipher;
 import org.apache.parquet.format.converter.ParquetMetadataConverter;
 import org.apache.parquet.hadoop.CodecFactory.BytesCompressor;
 import org.apache.parquet.hadoop.metadata.ColumnPath;
+import org.apache.parquet.hadoop.metadata.CompressionCodecName;
 import org.apache.parquet.internal.column.columnindex.ColumnIndexBuilder;
 import org.apache.parquet.internal.column.columnindex.OffsetIndexBuilder;
 import org.apache.parquet.io.ParquetEncodingException;
@@ -583,21 +587,7 @@ public class ColumnChunkPageWriteStore implements 
PageWriteStore, BloomFilterWri
       int columnIndexTruncateLength,
       boolean pageWriteChecksumEnabled) {
     this.schema = schema;
-    for (ColumnDescriptor path : schema.getColumns()) {
-      writers.put(
-          path,
-          new ColumnChunkPageWriter(
-              path,
-              compressor,
-              allocator,
-              columnIndexTruncateLength,
-              pageWriteChecksumEnabled,
-              null,
-              null,
-              null,
-              -1,
-              -1));
-    }
+    initWriters(col -> compressor, allocator, columnIndexTruncateLength, 
pageWriteChecksumEnabled, null, -1);
   }
 
   @Deprecated
@@ -628,13 +618,138 @@ public class ColumnChunkPageWriteStore implements 
PageWriteStore, BloomFilterWri
       InternalFileEncryptor fileEncryptor,
       int rowGroupOrdinal) {
     this.schema = schema;
+    initWriters(
+        col -> compressor,
+        allocator,
+        columnIndexTruncateLength,
+        pageWriteChecksumEnabled,
+        fileEncryptor,
+        rowGroupOrdinal);
+  }
+
+  private ColumnChunkPageWriteStore(
+      Function<ColumnDescriptor, BytesInputCompressor> compressorProvider,
+      MessageType schema,
+      ByteBufferAllocator allocator,
+      int columnIndexTruncateLength,
+      boolean pageWriteChecksumEnabled,
+      InternalFileEncryptor fileEncryptor,
+      int rowGroupOrdinal) {
+    this.schema = schema;
+    initWriters(
+        compressorProvider,
+        allocator,
+        columnIndexTruncateLength,
+        pageWriteChecksumEnabled,
+        fileEncryptor,
+        rowGroupOrdinal);
+  }
+
+  static Function<ColumnDescriptor, BytesInputCompressor> compressorProvider(
+      CompressionCodecFactory codecFactory,
+      CompressionCodecName defaultCodec,
+      ParquetProperties parquetProperties) {
+    return column -> resolveCompressor(column, codecFactory, defaultCodec, 
parquetProperties);
+  }
+
+  private static BytesInputCompressor resolveCompressor(
+      ColumnDescriptor column,
+      CompressionCodecFactory codecFactory,
+      CompressionCodecName defaultCodec,
+      ParquetProperties parquetProperties) {
+    CompressionCodecName columnCodec = 
parquetProperties.getColumnCodec(column);
+    CompressionCodecName codec = columnCodec != null ? columnCodec : 
defaultCodec;
+    Integer level = parquetProperties.getColumnCompressionLevel(column);
+    if (level != null && columnCodec == null) {
+      LOG.warn(
+          "Column '{}': compression level {} set without a per-column codec; "
+              + "applying level to the default codec ({}).",
+          ColumnPath.get(column.getPath()),
+          level,
+          defaultCodec);
+    }
+    return level != null ? codecFactory.getCompressor(codec, level) : 
codecFactory.getCompressor(codec);
+  }
+
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+    private Function<ColumnDescriptor, BytesInputCompressor> 
compressorProvider;
+    private MessageType schema;
+    private ByteBufferAllocator allocator;
+    private int columnIndexTruncateLength = 
ParquetProperties.DEFAULT_COLUMN_INDEX_TRUNCATE_LENGTH;
+    private boolean pageWriteChecksumEnabled = 
ParquetProperties.DEFAULT_PAGE_WRITE_CHECKSUM_ENABLED;
+    private InternalFileEncryptor fileEncryptor = null;
+    private int rowGroupOrdinal = -1;
+
+    private Builder() {}
+
+    public Builder withCompressorProvider(Function<ColumnDescriptor, 
BytesInputCompressor> compressorProvider) {
+      this.compressorProvider = compressorProvider;
+      return this;
+    }
+
+    public Builder withSchema(MessageType schema) {
+      this.schema = schema;
+      return this;
+    }
+
+    public Builder withAllocator(ByteBufferAllocator allocator) {
+      this.allocator = allocator;
+      return this;
+    }
+
+    public Builder withColumnIndexTruncateLength(int 
columnIndexTruncateLength) {
+      this.columnIndexTruncateLength = columnIndexTruncateLength;
+      return this;
+    }
+
+    public Builder withPageWriteChecksumEnabled(boolean 
pageWriteChecksumEnabled) {
+      this.pageWriteChecksumEnabled = pageWriteChecksumEnabled;
+      return this;
+    }
+
+    public Builder withFileEncryptor(InternalFileEncryptor fileEncryptor) {
+      this.fileEncryptor = fileEncryptor;
+      return this;
+    }
+
+    public Builder withRowGroupOrdinal(int rowGroupOrdinal) {
+      this.rowGroupOrdinal = rowGroupOrdinal;
+      return this;
+    }
+
+    public ColumnChunkPageWriteStore build() {
+      Objects.requireNonNull(compressorProvider, "compressorProvider cannot be 
null");
+      Objects.requireNonNull(schema, "schema cannot be null");
+      Objects.requireNonNull(allocator, "allocator cannot be null");
+      return new ColumnChunkPageWriteStore(
+          compressorProvider,
+          schema,
+          allocator,
+          columnIndexTruncateLength,
+          pageWriteChecksumEnabled,
+          fileEncryptor,
+          rowGroupOrdinal);
+    }
+  }
+
+  private void initWriters(
+      Function<ColumnDescriptor, BytesInputCompressor> compressorFn,
+      ByteBufferAllocator allocator,
+      int columnIndexTruncateLength,
+      boolean pageWriteChecksumEnabled,
+      InternalFileEncryptor fileEncryptor,
+      int rowGroupOrdinal) {
     if (null == fileEncryptor) {
       for (ColumnDescriptor path : schema.getColumns()) {
         writers.put(
             path,
             new ColumnChunkPageWriter(
                 path,
-                compressor,
+                compressorFn.apply(path),
                 allocator,
                 columnIndexTruncateLength,
                 pageWriteChecksumEnabled,
@@ -666,7 +781,7 @@ public class ColumnChunkPageWriteStore implements 
PageWriteStore, BloomFilterWri
           path,
           new ColumnChunkPageWriter(
               path,
-              compressor,
+              compressorFn.apply(path),
               allocator,
               columnIndexTruncateLength,
               pageWriteChecksumEnabled,
diff --git 
a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/DirectCodecFactory.java
 
b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/DirectCodecFactory.java
index b2b5233ee..312ccedf8 100644
--- 
a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/DirectCodecFactory.java
+++ 
b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/DirectCodecFactory.java
@@ -110,6 +110,20 @@ class DirectCodecFactory extends CodecFactory implements 
AutoCloseable {
     }
   }
 
+  @Override
+  protected BytesCompressor createCompressorAtLevel(final CompressionCodecName 
codecName, final int level) {
+    switch (codecName) {
+      case SNAPPY:
+        // Snappy has no compression level; keep using the direct compressor.
+        LOG.warn("Compression level {} is not supported for codec {} and will 
be ignored.", level, codecName);
+        return new SnappyCompressor();
+      case ZSTD:
+        return new ZstdCompressor(level);
+      default:
+        return super.createCompressorAtLevel(codecName, level);
+    }
+  }
+
   @Override
   protected BytesDecompressor createDecompressor(final CompressionCodecName 
codecName) {
     switch (codecName) {
@@ -409,11 +423,21 @@ class DirectCodecFactory extends CodecFactory implements 
AutoCloseable {
     private final ZstdCompressCtx context;
 
     ZstdCompressor() {
-      context = new ZstdCompressCtx();
-      context.setLevel(conf.getInt(
+      context = newContext(conf.getInt(
           ZstandardCodec.PARQUET_COMPRESS_ZSTD_LEVEL, 
ZstandardCodec.DEFAULT_PARQUET_COMPRESS_ZSTD_LEVEL));
-      context.setWorkers(conf.getInt(
+    }
+
+    ZstdCompressor(int level) {
+      validateZstdLevel(level);
+      context = newContext(level);
+    }
+
+    private ZstdCompressCtx newContext(int level) {
+      ZstdCompressCtx ctx = new ZstdCompressCtx();
+      ctx.setLevel(level);
+      ctx.setWorkers(conf.getInt(
           ZstandardCodec.PARQUET_COMPRESS_ZSTD_WORKERS, 
ZstandardCodec.DEFAULTPARQUET_COMPRESS_ZSTD_WORKERS));
+      return ctx;
     }
 
     @Override
diff --git 
a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/InternalParquetRecordWriter.java
 
b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/InternalParquetRecordWriter.java
index dd51d1ef0..d503cbb4e 100644
--- 
a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/InternalParquetRecordWriter.java
+++ 
b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/InternalParquetRecordWriter.java
@@ -25,6 +25,8 @@ import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
+import java.util.function.Function;
+import org.apache.parquet.column.ColumnDescriptor;
 import org.apache.parquet.column.ColumnWriteStore;
 import org.apache.parquet.column.ParquetProperties;
 import org.apache.parquet.column.values.bloomfilter.BloomFilterWriteStore;
@@ -51,7 +53,7 @@ class InternalParquetRecordWriter<T> {
   private long rowGroupSizeThreshold;
   private final int rowGroupRecordCountThreshold;
   private long nextRowGroupSize;
-  private final BytesInputCompressor compressor;
+  private final Function<ColumnDescriptor, BytesInputCompressor> 
compressorProvider;
   private final boolean validating;
   private final ParquetProperties props;
 
@@ -76,7 +78,6 @@ class InternalParquetRecordWriter<T> {
    * @param schema            the schema of the records
    * @param extraMetaData     extra meta data to write in the footer of the 
file
    * @param rowGroupSize      the size of a block in the file (this will be 
approximate)
-   * @param compressor        the codec used to compress
    */
   public InternalParquetRecordWriter(
       ParquetFileWriter parquetFileWriter,
@@ -84,7 +85,7 @@ class InternalParquetRecordWriter<T> {
       MessageType schema,
       Map<String, String> extraMetaData,
       long rowGroupSize,
-      BytesInputCompressor compressor,
+      Function<ColumnDescriptor, BytesInputCompressor> compressorProvider,
       boolean validating,
       ParquetProperties props) {
     this.parquetFileWriter = parquetFileWriter;
@@ -94,7 +95,7 @@ class InternalParquetRecordWriter<T> {
     this.rowGroupSizeThreshold = rowGroupSize;
     this.rowGroupRecordCountThreshold = props.getRowGroupRowCountLimit();
     this.nextRowGroupSize = rowGroupSizeThreshold;
-    this.compressor = compressor;
+    this.compressorProvider = compressorProvider;
     this.validating = validating;
     this.props = props;
     this.fileEncryptor = parquetFileWriter.getEncryptor();
@@ -108,14 +109,15 @@ class InternalParquetRecordWriter<T> {
   }
 
   private void initStore() {
-    ColumnChunkPageWriteStore columnChunkPageWriteStore = new 
ColumnChunkPageWriteStore(
-        compressor,
-        schema,
-        props.getAllocator(),
-        props.getColumnIndexTruncateLength(),
-        props.getPageWriteChecksumEnabled(),
-        fileEncryptor,
-        rowGroupOrdinal);
+    ColumnChunkPageWriteStore columnChunkPageWriteStore = 
ColumnChunkPageWriteStore.builder()
+        .withCompressorProvider(compressorProvider)
+        .withSchema(schema)
+        .withAllocator(props.getAllocator())
+        .withColumnIndexTruncateLength(props.getColumnIndexTruncateLength())
+        .withPageWriteChecksumEnabled(props.getPageWriteChecksumEnabled())
+        .withFileEncryptor(fileEncryptor)
+        .withRowGroupOrdinal(rowGroupOrdinal)
+        .build();
     pageStore = columnChunkPageWriteStore;
     bloomFilterWriteStore = columnChunkPageWriteStore;
 
diff --git 
a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetOutputFormat.java
 
b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetOutputFormat.java
index 868ae634c..4db288f45 100644
--- 
a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetOutputFormat.java
+++ 
b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetOutputFormat.java
@@ -163,6 +163,7 @@ public class ParquetOutputFormat<T> extends 
FileOutputFormat<Void, T> {
   public static final String PAGE_WRITE_CHECKSUM_ENABLED = 
"parquet.page.write-checksum.enabled";
   public static final String STATISTICS_ENABLED = 
"parquet.column.statistics.enabled";
   public static final String SIZE_STATISTICS_ENABLED = 
"parquet.size.statistics.enabled";
+  public static final String COLUMN_COMPRESSION_LEVEL_PREFIX = 
"parquet.compression.level";
 
   public static JobSummaryLevel getJobSummaryLevel(Configuration conf) {
     String level = conf.get(JOB_SUMMARY_LEVEL);
@@ -222,6 +223,14 @@ public class ParquetOutputFormat<T> extends 
FileOutputFormat<Void, T> {
     getConfiguration(job).set(COMPRESSION, compression.name());
   }
 
+  public static void setColumnCompression(Job job, String columnPath, 
CompressionCodecName codec) {
+    getConfiguration(job).set(COMPRESSION + '#' + columnPath, codec.name());
+  }
+
+  public static void setColumnCompressionLevel(Job job, String columnPath, int 
level) {
+    getConfiguration(job).setInt(COLUMN_COMPRESSION_LEVEL_PREFIX + '#' + 
columnPath, level);
+  }
+
   public static void setEnableDictionary(Job job, boolean enableDictionary) {
     getConfiguration(job).setBoolean(ENABLE_DICTIONARY, enableDictionary);
   }
@@ -546,6 +555,14 @@ public class ParquetOutputFormat<T> extends 
FileOutputFormat<Void, T> {
             STATISTICS_ENABLED,
             key -> conf.getBoolean(key, 
ParquetProperties.DEFAULT_STATISTICS_ENABLED),
             propsBuilder::withStatisticsEnabled)
+        .withColumnConfig(
+            COMPRESSION,
+            key -> CompressionCodecName.fromConf(conf.get(key)),
+            propsBuilder::withCompressionCodec)
+        .withColumnConfig(
+            COLUMN_COMPRESSION_LEVEL_PREFIX,
+            key -> conf.getInt(key, -1),
+            propsBuilder::withCompressionLevel)
         .parseConfig(conf);
 
     ParquetProperties props = propsBuilder.build();
diff --git 
a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetRecordWriter.java
 
b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetRecordWriter.java
index 51528b10b..80a6c69d1 100644
--- 
a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetRecordWriter.java
+++ 
b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetRecordWriter.java
@@ -104,7 +104,7 @@ public class ParquetRecordWriter<T> extends 
RecordWriter<Void, T> {
         .withWriterVersion(writerVersion)
         .build();
     internalWriter = new InternalParquetRecordWriter<T>(
-        w, writeSupport, schema, extraMetaData, blockSize, compressor, 
validating, props);
+        w, writeSupport, schema, extraMetaData, blockSize, col -> compressor, 
validating, props);
     this.memoryManager = null;
     this.codecFactory = null;
   }
@@ -173,7 +173,7 @@ public class ParquetRecordWriter<T> extends 
RecordWriter<Void, T> {
         .withWriterVersion(writerVersion)
         .build();
     internalWriter = new InternalParquetRecordWriter<T>(
-        w, writeSupport, schema, extraMetaData, blockSize, compressor, 
validating, props);
+        w, writeSupport, schema, extraMetaData, blockSize, col -> compressor, 
validating, props);
     this.memoryManager = Objects.requireNonNull(memoryManager, "memoryManager 
cannot be null");
     memoryManager.addWriter(internalWriter, blockSize);
     this.codecFactory = null;
@@ -207,7 +207,7 @@ public class ParquetRecordWriter<T> extends 
RecordWriter<Void, T> {
         schema,
         extraMetaData,
         blockSize,
-        codecFactory.getCompressor(codec),
+        ColumnChunkPageWriteStore.compressorProvider(codecFactory, codec, 
props),
         validating,
         props);
     this.memoryManager = Objects.requireNonNull(memoryManager, "memoryManager 
cannot be null");
diff --git 
a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetWriter.java 
b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetWriter.java
index 8eb5f7f17..7dcbb3188 100644
--- a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetWriter.java
+++ b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetWriter.java
@@ -395,7 +395,6 @@ public class ParquetWriter<T> implements Closeable {
     fileWriter.start();
 
     this.codecFactory = codecFactory;
-    CompressionCodecFactory.BytesInputCompressor compressor = 
codecFactory.getCompressor(compressionCodecName);
 
     final Map<String, String> extraMetadata;
     if (encodingProps.getExtraMetaData() == null
@@ -418,7 +417,14 @@ public class ParquetWriter<T> implements Closeable {
     }
 
     this.writer = new InternalParquetRecordWriter<T>(
-        fileWriter, writeSupport, schema, extraMetadata, rowGroupSize, 
compressor, validating, encodingProps);
+        fileWriter,
+        writeSupport,
+        schema,
+        extraMetadata,
+        rowGroupSize,
+        ColumnChunkPageWriteStore.compressorProvider(codecFactory, 
compressionCodecName, encodingProps),
+        validating,
+        encodingProps);
   }
 
   public void write(T object) throws IOException {
@@ -569,6 +575,32 @@ public class ParquetWriter<T> implements Closeable {
       return self();
     }
 
+    /**
+     * Override the compression codec for a specific column.
+     *
+     * @param columnPath dot-string path of the column (e.g. {@code "my_col"})
+     * @param codecName  the codec to use for that column
+     * @return this builder for method chaining.
+     */
+    public SELF withCompressionCodec(String columnPath, CompressionCodecName 
codecName) {
+      encodingPropsBuilder.withCompressionCodec(columnPath, codecName);
+      return self();
+    }
+
+    /**
+     * Set the compression level for a specific column.
+     * The valid range is codec-specific (e.g. ZSTD: negative levels trade 
ratio for speed, up to 22 for best compression (default 3), GZIP: 0–9 or -1 
(default 6), BROTLI: 0–11 (default 1)).
+     * Pass {@code null} to unset a previously configured level for that 
column.
+     *
+     * @param columnPath dot-string path of the column (e.g. {@code "my_col"})
+     * @param level      codec-specific compression level, or {@code null} to 
unset
+     * @return this builder for method chaining.
+     */
+    public SELF withCompressionLevel(String columnPath, Integer level) {
+      encodingPropsBuilder.withCompressionLevel(columnPath, level);
+      return self();
+    }
+
     /**
      * Set the {@link CompressionCodecFactory codec factory} used by the
      * constructed writer.
diff --git 
a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestColumnChunkPageWriteStore.java
 
b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestColumnChunkPageWriteStore.java
index 8d735b55a..bd7bbf484 100644
--- 
a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestColumnChunkPageWriteStore.java
+++ 
b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestColumnChunkPageWriteStore.java
@@ -22,7 +22,9 @@ import static org.apache.parquet.column.Encoding.PLAIN;
 import static org.apache.parquet.column.Encoding.RLE;
 import static 
org.apache.parquet.format.converter.ParquetMetadataConverter.NO_FILTER;
 import static org.apache.parquet.hadoop.metadata.CompressionCodecName.GZIP;
+import static org.apache.parquet.hadoop.metadata.CompressionCodecName.SNAPPY;
 import static 
org.apache.parquet.hadoop.metadata.CompressionCodecName.UNCOMPRESSED;
+import static org.apache.parquet.hadoop.metadata.CompressionCodecName.ZSTD;
 import static org.apache.parquet.schema.OriginalType.UTF8;
 import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BINARY;
 import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.DOUBLE;
@@ -32,6 +34,8 @@ import static 
org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT64;
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
@@ -41,6 +45,7 @@ import static org.mockito.Mockito.inOrder;
 import java.io.IOException;
 import java.nio.ByteOrder;
 import java.util.HashMap;
+import java.util.Map;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.fs.FileSystem;
 import org.apache.hadoop.fs.Path;
@@ -312,4 +317,132 @@ public class TestColumnChunkPageWriteStore {
   private BytesInputCompressor compressor(CompressionCodecName codec) {
     return new CodecFactory(conf, pageSize).getCompressor(codec);
   }
+
+  @Test
+  public void perColumnCodecDefaultUsedWhenNotSet() throws Exception {
+    MessageType schema =
+        MessageTypeParser.parseMessageType("message test { required binary 
col_a; required int32 col_b; }");
+    ParquetProperties props = ParquetProperties.builder().build();
+
+    Map<String, CompressionCodecName> codecs = writeAndReadCodecs(schema, 
SNAPPY, props);
+
+    assertEquals(SNAPPY, codecs.get("col_a"));
+    assertEquals(SNAPPY, codecs.get("col_b"));
+  }
+
+  @Test
+  public void perColumnCodecOverridesDefaultForOneColumn() throws Exception {
+    MessageType schema =
+        MessageTypeParser.parseMessageType("message test { required binary 
col_a; required int32 col_b; }");
+    ParquetProperties props =
+        ParquetProperties.builder().withCompressionCodec("col_a", 
ZSTD).build();
+
+    Map<String, CompressionCodecName> codecs = writeAndReadCodecs(schema, 
SNAPPY, props);
+
+    assertEquals(ZSTD, codecs.get("col_a"));
+    assertEquals(SNAPPY, codecs.get("col_b"));
+  }
+
+  @Test
+  public void perColumnCodecAllColumnsOverridden() throws Exception {
+    MessageType schema =
+        MessageTypeParser.parseMessageType("message test { required binary 
col_a; required int32 col_b; }");
+    ParquetProperties props = ParquetProperties.builder()
+        .withCompressionCodec("col_a", ZSTD)
+        .withCompressionCodec("col_b", GZIP)
+        .build();
+
+    Map<String, CompressionCodecName> codecs = writeAndReadCodecs(schema, 
SNAPPY, props);
+
+    assertEquals(ZSTD, codecs.get("col_a"));
+    assertEquals(GZIP, codecs.get("col_b"));
+  }
+
+  @Test
+  public void perColumnLevelWithCodecRoundTrip() throws Exception {
+    MessageType schema =
+        MessageTypeParser.parseMessageType("message test { required binary 
col_a; required int32 col_b; }");
+    ParquetProperties props = ParquetProperties.builder()
+        .withCompressionCodec("col_a", ZSTD)
+        .withCompressionLevel("col_a", 10)
+        .build();
+
+    Map<String, CompressionCodecName> codecs = writeAndReadCodecs(schema, 
SNAPPY, props);
+
+    assertEquals(ZSTD, codecs.get("col_a"));
+    assertEquals(SNAPPY, codecs.get("col_b"));
+  }
+
+  @Test
+  public void perColumnLevelInvalidZstdLevelThrows() throws Exception {
+    MessageType schema = MessageTypeParser.parseMessageType("message test { 
required binary col_a; }");
+    ParquetProperties props = ParquetProperties.builder()
+        .withCompressionCodec("col_a", ZSTD)
+        .withCompressionLevel("col_a", 23)
+        .build();
+
+    BadConfigurationException ex =
+        assertThrows(BadConfigurationException.class, () -> 
writeAndReadCodecs(schema, SNAPPY, props));
+    assertTrue(ex.getMessage().contains("23"));
+  }
+
+  @Test
+  public void perColumnLevelInvalidGzipLevelThrows() throws Exception {
+    MessageType schema = MessageTypeParser.parseMessageType("message test { 
required binary col_a; }");
+    ParquetProperties props = ParquetProperties.builder()
+        .withCompressionCodec("col_a", GZIP)
+        .withCompressionLevel("col_a", 10)
+        .build();
+
+    BadConfigurationException ex =
+        assertThrows(BadConfigurationException.class, () -> 
writeAndReadCodecs(schema, SNAPPY, props));
+    assertTrue(ex.getMessage().contains("10"));
+  }
+
+  private Map<String, CompressionCodecName> writeAndReadCodecs(
+      MessageType schema, CompressionCodecName defaultCodec, ParquetProperties 
props) throws Exception {
+    Path file = new 
Path("target/test/TestColumnChunkPageWriteStore/perColumnCodec.parquet");
+    FileSystem fs = file.getFileSystem(conf);
+    fs.delete(file, false);
+    fs.mkdirs(file.getParent());
+
+    allocator = TrackingByteBufferAllocator.wrap(new 
HeapByteBufferAllocator());
+    CodecFactory codecFactory = new CodecFactory(conf, pageSize);
+
+    OutputFileForTesting outputFile = new OutputFileForTesting(file, conf);
+    ParquetFileWriter writer = new ParquetFileWriter(
+        outputFile,
+        schema,
+        Mode.CREATE,
+        ParquetWriter.DEFAULT_BLOCK_SIZE,
+        ParquetWriter.MAX_PADDING_SIZE_DEFAULT,
+        null,
+        ParquetProperties.builder().withAllocator(allocator).build());
+    writer.start();
+    writer.startBlock(1);
+
+    try (ColumnChunkPageWriteStore store = ColumnChunkPageWriteStore.builder()
+        
.withCompressorProvider(ColumnChunkPageWriteStore.compressorProvider(codecFactory,
 defaultCodec, props))
+        .withSchema(schema)
+        .withAllocator(allocator)
+        .withColumnIndexTruncateLength(Integer.MAX_VALUE)
+        .build()) {
+      for (ColumnDescriptor col : schema.getColumns()) {
+        Statistics<?> stats =
+            Statistics.getBuilderForReading(col.getPrimitiveType()).build();
+        store.getPageWriter(col).writePage(BytesInput.fromInt(42), 1, 1, 
stats, RLE, RLE, PLAIN);
+      }
+      store.flushToFileWriter(writer);
+    }
+
+    writer.endBlock();
+    writer.end(new HashMap<>());
+
+    ParquetMetadata footer = ParquetFileReader.readFooter(conf, file, 
NO_FILTER);
+    Map<String, CompressionCodecName> result = new HashMap<>();
+    for (ColumnChunkMetaData col : footer.getBlocks().get(0).getColumns()) {
+      result.put(col.getPath().toDotString(), col.getCodec());
+    }
+    return result;
+  }
 }
diff --git 
a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestDirectCodecFactory.java
 
b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestDirectCodecFactory.java
index c78ee09ec..e4be00947 100644
--- 
a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestDirectCodecFactory.java
+++ 
b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestDirectCodecFactory.java
@@ -18,12 +18,17 @@
 package org.apache.parquet.hadoop;
 
 import static org.apache.parquet.hadoop.metadata.CompressionCodecName.BROTLI;
+import static org.apache.parquet.hadoop.metadata.CompressionCodecName.GZIP;
 import static org.apache.parquet.hadoop.metadata.CompressionCodecName.LZ4;
 import static org.apache.parquet.hadoop.metadata.CompressionCodecName.LZ4_RAW;
 import static org.apache.parquet.hadoop.metadata.CompressionCodecName.LZO;
+import static org.apache.parquet.hadoop.metadata.CompressionCodecName.SNAPPY;
+import static 
org.apache.parquet.hadoop.metadata.CompressionCodecName.UNCOMPRESSED;
+import static org.apache.parquet.hadoop.metadata.CompressionCodecName.ZSTD;
 
 import java.io.IOException;
 import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
 import java.util.HashSet;
 import java.util.Random;
 import java.util.Set;
@@ -285,4 +290,158 @@ public class TestDirectCodecFactory {
     Assert.assertEquals(codec_2_1, codec_2_2);
     Assert.assertNotEquals(codec_2_1, codec_5_1);
   }
+
+  @Test
+  public void leveledCompressorCachedForSameLevel() {
+    CodecFactory factory = new CodecFactory(new Configuration(), pageSize);
+    BytesInputCompressor c1 = factory.getCompressor(ZSTD, 3);
+    BytesInputCompressor c2 = factory.getCompressor(ZSTD, 3);
+    Assert.assertSame("Same codec+level should return the cached instance", 
c1, c2);
+    factory.release();
+  }
+
+  @Test
+  public void leveledCompressorDiffersByLevel() {
+    CodecFactory factory = new CodecFactory(new Configuration(), pageSize);
+    BytesInputCompressor c1 = factory.getCompressor(ZSTD, 1);
+    BytesInputCompressor c3 = factory.getCompressor(ZSTD, 3);
+    Assert.assertNotSame("Different levels should return different compressor 
instances", c1, c3);
+    factory.release();
+  }
+
+  @Test
+  public void leveledCacheIsolatedFromNoLevelCache() {
+    CodecFactory factory = new CodecFactory(new Configuration(), pageSize);
+    BytesInputCompressor noLevel = factory.getCompressor(ZSTD);
+    BytesInputCompressor withLevel = factory.getCompressor(ZSTD, 3);
+    Assert.assertNotSame(
+        "Level-aware and no-level compressors should use separate cache 
entries", noLevel, withLevel);
+    factory.release();
+  }
+
+  @Test
+  public void leveledUncompressedReturnsNoOp() {
+    CodecFactory factory = new CodecFactory(new Configuration(), pageSize);
+    BytesInputCompressor comp = factory.getCompressor(UNCOMPRESSED, 5);
+    Assert.assertSame(CodecFactory.NO_OP_COMPRESSOR, comp);
+    factory.release();
+  }
+
+  @Test
+  public void leveledSnappyIgnoresLevel() {
+    CodecFactory factory = new CodecFactory(new Configuration(), pageSize);
+    BytesInputCompressor comp = factory.getCompressor(SNAPPY, 99);
+    Assert.assertNotNull(comp);
+    Assert.assertEquals(SNAPPY, comp.getCodecName());
+    factory.release();
+  }
+
+  @Test
+  public void leveledGzipInvalidLevelThrows() {
+    CodecFactory factory = new CodecFactory(new Configuration(), pageSize);
+    try {
+      BadConfigurationException ex =
+          Assert.assertThrows(BadConfigurationException.class, () -> 
factory.getCompressor(GZIP, 99));
+      Assert.assertTrue(ex.getMessage().contains("99"));
+    } finally {
+      factory.release();
+    }
+  }
+
+  @Test
+  public void leveledGzipBoundaryLevelsValid() {
+    CodecFactory factory = new CodecFactory(new Configuration(), pageSize);
+    for (int level : new int[] {-1, 0, 1, 9}) {
+      BytesInputCompressor comp = factory.getCompressor(GZIP, level);
+      Assert.assertNotNull("Compressor should not be null for GZIP level " + 
level, comp);
+      Assert.assertEquals("Codec name should be GZIP for level " + level, 
GZIP, comp.getCodecName());
+    }
+    factory.release();
+  }
+
+  @Test
+  public void leveledZstdRoundTrip() throws IOException {
+    CodecFactory factory = new CodecFactory(new Configuration(), pageSize);
+    byte[] original = "hello parquet per-column 
compression".getBytes(StandardCharsets.UTF_8);
+    BytesInputDecompressor decompressor = factory.getDecompressor(ZSTD);
+    for (int level : new int[] {-5, 0, 1, 3, 10, 22}) {
+      BytesInput compressed = factory.getCompressor(ZSTD, 
level).compress(BytesInput.from(original));
+      byte[] result = decompressor.decompress(compressed, 
original.length).toByteArray();
+      Assert.assertArrayEquals("Round-trip failed at ZSTD level " + level, 
original, result);
+    }
+    factory.release();
+  }
+
+  @Test
+  public void leveledGzipRoundTrip() throws IOException {
+    CodecFactory factory = new CodecFactory(new Configuration(), pageSize);
+    byte[] original = "hello parquet per-column 
compression".getBytes(StandardCharsets.UTF_8);
+    BytesInputDecompressor decompressor = factory.getDecompressor(GZIP);
+    for (int level : new int[] {1, 5, 9}) {
+      BytesInput compressed = factory.getCompressor(GZIP, 
level).compress(BytesInput.from(original));
+      byte[] result = decompressor.decompress(compressed, 
original.length).toByteArray();
+      Assert.assertArrayEquals("Round-trip failed at GZIP level " + level, 
original, result);
+    }
+    factory.release();
+  }
+
+  @Test
+  public void directFactoryLeveledZstdRoundTrip() throws IOException {
+    CodecFactory heap = new CodecFactory(new Configuration(), pageSize);
+    CodecFactory direct =
+        CodecFactory.createDirectCodecFactory(new Configuration(), new 
DirectByteBufferAllocator(), pageSize);
+    try {
+      // Sanity: the heap factory's leveled path yields a HeapBytesCompressor.
+      Assert.assertTrue(
+          "heap factory should produce a HeapBytesCompressor",
+          heap.getCompressor(ZSTD, 3) instanceof 
CodecFactory.HeapBytesCompressor);
+
+      // The direct factory must not fall back to the heap/Hadoop path for 
leveled ZSTD/SNAPPY.
+      BytesInputCompressor directZstd = direct.getCompressor(ZSTD, 3);
+      Assert.assertFalse(
+          "direct factory ZSTD(level) should not fall back to 
HeapBytesCompressor",
+          directZstd instanceof CodecFactory.HeapBytesCompressor);
+      Assert.assertEquals(ZSTD, directZstd.getCodecName());
+      Assert.assertEquals(
+          "leveled ZSTD should use the same direct compressor type as the 
no-level path",
+          direct.getCompressor(ZSTD).getClass(),
+          directZstd.getClass());
+
+      BytesInputCompressor directSnappy = direct.getCompressor(SNAPPY, 5);
+      Assert.assertFalse(
+          "direct factory SNAPPY(level) should not fall back to 
HeapBytesCompressor",
+          directSnappy instanceof CodecFactory.HeapBytesCompressor);
+      Assert.assertEquals(
+          "leveled SNAPPY should use the same direct compressor type as the 
no-level path",
+          direct.getCompressor(SNAPPY).getClass(),
+          directSnappy.getClass());
+
+      // The direct ZSTD level path must compress/decompress correctly at each 
level.
+      byte[] original = "hello parquet per-column zstd direct 
compression".getBytes(StandardCharsets.UTF_8);
+      BytesInputDecompressor decompressor = heap.getDecompressor(ZSTD);
+      for (int level : new int[] {1, 3, 22}) {
+        byte[] compressed = direct.getCompressor(ZSTD, level)
+            .compress(BytesInput.from(original))
+            .toByteArray();
+        byte[] result = decompressor
+            .decompress(BytesInput.from(compressed), original.length)
+            .toByteArray();
+        Assert.assertArrayEquals("Direct ZSTD round-trip failed at level " + 
level, original, result);
+      }
+    } finally {
+      heap.release();
+      direct.release();
+    }
+  }
+
+  @Test
+  public void directFactoryInvalidZstdLevelThrows() {
+    CodecFactory direct =
+        CodecFactory.createDirectCodecFactory(new Configuration(), new 
DirectByteBufferAllocator(), pageSize);
+    try {
+      Assert.assertThrows(BadConfigurationException.class, () -> 
direct.getCompressor(ZSTD, 23));
+    } finally {
+      direct.release();
+    }
+  }
 }
diff --git 
a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestParquetWriter.java 
b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestParquetWriter.java
index 6b3b0aae2..02e114a0f 100644
--- 
a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestParquetWriter.java
+++ 
b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestParquetWriter.java
@@ -27,7 +27,10 @@ import static 
org.apache.parquet.column.ParquetProperties.WriterVersion.PARQUET_
 import static 
org.apache.parquet.format.converter.ParquetMetadataConverter.NO_FILTER;
 import static org.apache.parquet.hadoop.ParquetFileReader.readFooter;
 import static org.apache.parquet.hadoop.TestUtils.enforceEmptyDir;
+import static org.apache.parquet.hadoop.metadata.CompressionCodecName.GZIP;
+import static org.apache.parquet.hadoop.metadata.CompressionCodecName.SNAPPY;
 import static 
org.apache.parquet.hadoop.metadata.CompressionCodecName.UNCOMPRESSED;
+import static org.apache.parquet.hadoop.metadata.CompressionCodecName.ZSTD;
 import static org.apache.parquet.schema.LogicalTypeAnnotation.stringType;
 import static org.apache.parquet.schema.MessageTypeParser.parseMessageType;
 import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BINARY;
@@ -55,6 +58,8 @@ import org.apache.commons.lang3.RandomStringUtils;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.fs.FileSystem;
 import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.mapreduce.Job;
+import org.apache.hadoop.mapreduce.RecordWriter;
 import org.apache.parquet.ParquetReadOptions;
 import org.apache.parquet.bytes.HeapByteBufferAllocator;
 import org.apache.parquet.bytes.TrackingByteBufferAllocator;
@@ -86,6 +91,7 @@ import org.apache.parquet.hadoop.example.GroupWriteSupport;
 import org.apache.parquet.hadoop.metadata.BlockMetaData;
 import org.apache.parquet.hadoop.metadata.ColumnChunkMetaData;
 import org.apache.parquet.hadoop.metadata.ColumnPath;
+import org.apache.parquet.hadoop.metadata.CompressionCodecName;
 import org.apache.parquet.hadoop.metadata.ParquetMetadata;
 import org.apache.parquet.hadoop.util.HadoopInputFile;
 import org.apache.parquet.hadoop.util.HadoopOutputFile;
@@ -826,6 +832,231 @@ public class TestParquetWriter {
             ExampleParquetWriter.builder(path).withFile(outputFile).build());
   }
 
+  @Test
+  public void perColumnCodecOverridesDefaultForOneColumn() throws Exception {
+    MessageType schema = Types.buildMessage()
+        .required(BINARY)
+        .as(stringType())
+        .named("col_a")
+        .required(INT32)
+        .named("col_b")
+        .named("test");
+    File file = temp.newFile();
+    file.delete();
+    Path path = new Path(file.getAbsolutePath());
+
+    try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(path)
+        .withAllocator(allocator)
+        .withType(schema)
+        .withCompressionCodec(SNAPPY)
+        .withCompressionCodec("col_a", ZSTD)
+        .build()) {
+      SimpleGroupFactory f = new SimpleGroupFactory(schema);
+      writer.write(f.newGroup().append("col_a", "hello").append("col_b", 1));
+    }
+
+    ParquetMetadata footer = readFooter(new Configuration(), path, NO_FILTER);
+    Map<String, CompressionCodecName> codecs = new HashMap<>();
+    for (ColumnChunkMetaData col : footer.getBlocks().get(0).getColumns()) {
+      codecs.put(col.getPath().toDotString(), col.getCodec());
+    }
+    assertEquals(ZSTD, codecs.get("col_a"));
+    assertEquals(SNAPPY, codecs.get("col_b"));
+  }
+
+  @Test
+  public void perColumnCodecDefaultUsedWhenNoOverride() throws Exception {
+    MessageType schema = Types.buildMessage()
+        .required(BINARY)
+        .as(stringType())
+        .named("col_a")
+        .required(INT32)
+        .named("col_b")
+        .named("test");
+    File file = temp.newFile();
+    file.delete();
+    Path path = new Path(file.getAbsolutePath());
+
+    try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(path)
+        .withAllocator(allocator)
+        .withType(schema)
+        .withCompressionCodec(GZIP)
+        .build()) {
+      SimpleGroupFactory f = new SimpleGroupFactory(schema);
+      writer.write(f.newGroup().append("col_a", "hello").append("col_b", 1));
+    }
+
+    ParquetMetadata footer = readFooter(new Configuration(), path, NO_FILTER);
+    for (ColumnChunkMetaData col : footer.getBlocks().get(0).getColumns()) {
+      assertEquals("Column " + col.getPath().toDotString() + " should use 
default codec", GZIP, col.getCodec());
+    }
+  }
+
+  @Test
+  public void perColumnLevelDataRoundTrips() throws Exception {
+    MessageType schema = Types.buildMessage()
+        .required(BINARY)
+        .as(stringType())
+        .named("col_a")
+        .required(INT32)
+        .named("col_b")
+        .named("test");
+    File file = temp.newFile();
+    file.delete();
+    Path path = new Path(file.getAbsolutePath());
+
+    try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(path)
+        .withAllocator(allocator)
+        .withType(schema)
+        .withCompressionCodec(SNAPPY)
+        .withCompressionCodec("col_a", ZSTD)
+        .withCompressionLevel("col_a", 1)
+        .build()) {
+      SimpleGroupFactory f = new SimpleGroupFactory(schema);
+      writer.write(f.newGroup().append("col_a", "hello").append("col_b", 42));
+    }
+
+    try (ParquetReader<Group> reader =
+        ParquetReader.builder(new GroupReadSupport(), path).build()) {
+      Group group = reader.read();
+      assertEquals("hello", group.getBinary("col_a", 0).toStringUsingUTF8());
+      assertEquals(42, group.getInteger("col_b", 0));
+      assertNull(reader.read());
+    }
+
+    ParquetMetadata footer = readFooter(new Configuration(), path, NO_FILTER);
+    Map<String, CompressionCodecName> codecs = new HashMap<>();
+    for (ColumnChunkMetaData col : footer.getBlocks().get(0).getColumns()) {
+      codecs.put(col.getPath().toDotString(), col.getCodec());
+    }
+    assertEquals(ZSTD, codecs.get("col_a"));
+    assertEquals(SNAPPY, codecs.get("col_b"));
+  }
+
+  @Test
+  public void perColumnLevelInvalidZstdLevelThrows() throws Exception {
+    MessageType schema = Types.buildMessage()
+        .required(BINARY)
+        .as(stringType())
+        .named("col_a")
+        .named("test");
+    File file = temp.newFile();
+    file.delete();
+    Path path = new Path(file.getAbsolutePath());
+
+    // ZSTD only supports levels 1-22; level 23 is invalid
+    Assert.assertThrows(BadConfigurationException.class, () -> {
+      try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(path)
+          .withAllocator(allocator)
+          .withType(schema)
+          .withCompressionCodec("col_a", ZSTD)
+          .withCompressionLevel("col_a", 23)
+          .build()) {
+        // exception expected before first write
+      }
+    });
+  }
+
+  @Test
+  public void perColumnLevelInvalidLevelThrows() throws Exception {
+    MessageType schema = Types.buildMessage()
+        .required(BINARY)
+        .as(stringType())
+        .named("col_a")
+        .named("test");
+    File file = temp.newFile();
+    file.delete();
+    Path path = new Path(file.getAbsolutePath());
+
+    // GZIP only supports levels -1 (default) through 9; level 10 is invalid
+    Assert.assertThrows(BadConfigurationException.class, () -> {
+      try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(path)
+          .withAllocator(allocator)
+          .withType(schema)
+          .withCompressionCodec("col_a", GZIP)
+          .withCompressionLevel("col_a", 10)
+          .build()) {
+        // exception expected before first write
+      }
+    });
+  }
+
+  @Test
+  public void perColumnLevelDifferentLevelsPerColumnDataRoundTrips() throws 
Exception {
+    MessageType schema = Types.buildMessage()
+        .required(BINARY)
+        .as(stringType())
+        .named("col_a")
+        .required(BINARY)
+        .as(stringType())
+        .named("col_b")
+        .named("test");
+    File file = temp.newFile();
+    file.delete();
+    Path path = new Path(file.getAbsolutePath());
+
+    // Both columns use ZSTD (from default), but at different levels.
+    // This exercises the level-only override path in resolveCompressor().
+    try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(path)
+        .withAllocator(allocator)
+        .withType(schema)
+        .withCompressionCodec(ZSTD)
+        .withCompressionLevel("col_a", 1)
+        .withCompressionLevel("col_b", 10)
+        .build()) {
+      SimpleGroupFactory f = new SimpleGroupFactory(schema);
+      writer.write(f.newGroup().append("col_a", "fast").append("col_b", 
"best"));
+    }
+
+    // Both columns must report ZSTD in the footer
+    ParquetMetadata footer = readFooter(new Configuration(), path, NO_FILTER);
+    for (ColumnChunkMetaData col : footer.getBlocks().get(0).getColumns()) {
+      assertEquals("Column " + col.getPath().toDotString() + " should use 
ZSTD", ZSTD, col.getCodec());
+    }
+
+    // Data must survive the round-trip at both levels
+    try (ParquetReader<Group> reader =
+        ParquetReader.builder(new GroupReadSupport(), path).build()) {
+      Group group = reader.read();
+      assertEquals("fast", group.getBinary("col_a", 0).toStringUsingUTF8());
+      assertEquals("best", group.getBinary("col_b", 0).toStringUsingUTF8());
+      assertNull(reader.read());
+    }
+  }
+
+  @Test
+  public void perColumnCodecAllColumnsOverridden() throws Exception {
+    MessageType schema = Types.buildMessage()
+        .required(BINARY)
+        .as(stringType())
+        .named("col_a")
+        .required(INT32)
+        .named("col_b")
+        .named("test");
+    File file = temp.newFile();
+    file.delete();
+    Path path = new Path(file.getAbsolutePath());
+
+    try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(path)
+        .withAllocator(allocator)
+        .withType(schema)
+        .withCompressionCodec(SNAPPY)
+        .withCompressionCodec("col_a", ZSTD)
+        .withCompressionCodec("col_b", GZIP)
+        .build()) {
+      SimpleGroupFactory f = new SimpleGroupFactory(schema);
+      writer.write(f.newGroup().append("col_a", "hello").append("col_b", 1));
+    }
+
+    ParquetMetadata footer = readFooter(new Configuration(), path, NO_FILTER);
+    Map<String, CompressionCodecName> codecs = new HashMap<>();
+    for (ColumnChunkMetaData col : footer.getBlocks().get(0).getColumns()) {
+      codecs.put(col.getPath().toDotString(), col.getCodec());
+    }
+    assertEquals(ZSTD, codecs.get("col_a"));
+    assertEquals(GZIP, codecs.get("col_b"));
+  }
+
   @Test
   public void testNoFlushAfterException() throws Exception {
     final File testDir = temp.newFile();
@@ -864,6 +1095,163 @@ public class TestParquetWriter {
     assertTrue(!fs.exists(file) || fs.getFileStatus(file).getLen() == 0);
   }
 
+  @Test
+  public void outputFormatSetColumnCompressionOverridesDefaultForOneColumn() 
throws Exception {
+    MessageType schema = Types.buildMessage()
+        .required(BINARY)
+        .as(stringType())
+        .named("col_a")
+        .required(INT32)
+        .named("col_b")
+        .named("test");
+    File file = temp.newFile();
+    file.delete();
+    Path path = new Path(file.getAbsolutePath());
+    Job job = Job.getInstance();
+    ParquetOutputFormat.setColumnCompression(job, "col_a", ZSTD);
+
+    Map<String, CompressionCodecName> codecs = 
writeAndReadCodecsViaOutputFormat(schema, SNAPPY, job, path);
+
+    assertEquals(ZSTD, codecs.get("col_a"));
+    assertEquals(SNAPPY, codecs.get("col_b"));
+  }
+
+  @Test
+  public void outputFormatSetColumnCompressionAllColumnsOverridden() throws 
Exception {
+    MessageType schema = Types.buildMessage()
+        .required(BINARY)
+        .as(stringType())
+        .named("col_a")
+        .required(INT32)
+        .named("col_b")
+        .named("test");
+    File file = temp.newFile();
+    file.delete();
+    Path path = new Path(file.getAbsolutePath());
+    Job job = Job.getInstance();
+    ParquetOutputFormat.setColumnCompression(job, "col_a", ZSTD);
+    ParquetOutputFormat.setColumnCompression(job, "col_b", GZIP);
+
+    Map<String, CompressionCodecName> codecs = 
writeAndReadCodecsViaOutputFormat(schema, SNAPPY, job, path);
+
+    assertEquals(ZSTD, codecs.get("col_a"));
+    assertEquals(GZIP, codecs.get("col_b"));
+  }
+
+  @Test
+  public void outputFormatSetColumnCompressionDefaultUsedWhenNotSet() throws 
Exception {
+    MessageType schema = Types.buildMessage()
+        .required(BINARY)
+        .as(stringType())
+        .named("col_a")
+        .required(INT32)
+        .named("col_b")
+        .named("test");
+    File file = temp.newFile();
+    file.delete();
+    Path path = new Path(file.getAbsolutePath());
+    Job job = Job.getInstance();
+
+    Map<String, CompressionCodecName> codecs = 
writeAndReadCodecsViaOutputFormat(schema, GZIP, job, path);
+
+    assertEquals(GZIP, codecs.get("col_a"));
+    assertEquals(GZIP, codecs.get("col_b"));
+  }
+
+  @Test
+  public void outputFormatSetColumnCompressionLevelWithCodecDataRoundTrips() 
throws Exception {
+    MessageType schema = Types.buildMessage()
+        .required(BINARY)
+        .as(stringType())
+        .named("col_a")
+        .required(INT32)
+        .named("col_b")
+        .named("test");
+    File file = temp.newFile();
+    file.delete();
+    Path path = new Path(file.getAbsolutePath());
+    Job job = Job.getInstance();
+    ParquetOutputFormat.setColumnCompression(job, "col_a", ZSTD);
+    ParquetOutputFormat.setColumnCompressionLevel(job, "col_a", 1);
+
+    Map<String, CompressionCodecName> codecs = 
writeAndReadCodecsViaOutputFormat(schema, SNAPPY, job, path);
+    assertEquals(ZSTD, codecs.get("col_a"));
+    assertEquals(SNAPPY, codecs.get("col_b"));
+
+    try (ParquetReader<Group> reader =
+        ParquetReader.builder(new GroupReadSupport(), path).build()) {
+      Group group = reader.read();
+      assertEquals("hello", group.getBinary("col_a", 0).toStringUsingUTF8());
+      assertEquals(42, group.getInteger("col_b", 0));
+      assertNull(reader.read());
+    }
+  }
+
+  @Test
+  public void outputFormatSetColumnCompressionLevelDifferentLevelsPerColumn() 
throws Exception {
+    MessageType schema = Types.buildMessage()
+        .required(BINARY)
+        .as(stringType())
+        .named("col_a")
+        .required(BINARY)
+        .as(stringType())
+        .named("col_b")
+        .named("test");
+    File file = temp.newFile();
+    file.delete();
+    Path path = new Path(file.getAbsolutePath());
+    Job job = Job.getInstance();
+    ParquetOutputFormat.setColumnCompressionLevel(job, "col_a", 1);
+    ParquetOutputFormat.setColumnCompressionLevel(job, "col_b", 10);
+
+    Map<String, CompressionCodecName> codecs = 
writeAndReadCodecsViaOutputFormat(schema, ZSTD, job, path);
+    assertEquals(ZSTD, codecs.get("col_a"));
+    assertEquals(ZSTD, codecs.get("col_b"));
+
+    try (ParquetReader<Group> reader =
+        ParquetReader.builder(new GroupReadSupport(), path).build()) {
+      Group group = reader.read();
+      assertEquals("hello", group.getBinary("col_a", 0).toStringUsingUTF8());
+      assertEquals("fast", group.getBinary("col_b", 0).toStringUsingUTF8());
+      assertNull(reader.read());
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private Map<String, CompressionCodecName> writeAndReadCodecsViaOutputFormat(
+      MessageType schema, CompressionCodecName defaultCodec, Job job, Path 
file) throws Exception {
+    Configuration conf = job.getConfiguration();
+    GroupWriteSupport.setSchema(schema, conf);
+    SimpleGroupFactory f = new SimpleGroupFactory(schema);
+
+    Group group = f.newGroup();
+    for (int i = 0; i < schema.getFieldCount(); i++) {
+      String name = schema.getFieldName(i);
+      switch (schema.getType(i).asPrimitiveType().getPrimitiveTypeName()) {
+        case BINARY:
+          group.append(name, name.equals("col_a") ? "hello" : "fast");
+          break;
+        case INT32:
+          group.append(name, 42);
+          break;
+        default:
+          break;
+      }
+    }
+
+    ParquetOutputFormat<Group> outputFormat = new ParquetOutputFormat<>(new 
GroupWriteSupport());
+    RecordWriter<Void, Group> writer = outputFormat.getRecordWriter(conf, 
file, defaultCodec);
+    writer.write(null, group);
+    writer.close(null);
+
+    ParquetMetadata footer = readFooter(conf, file, NO_FILTER);
+    Map<String, CompressionCodecName> result = new HashMap<>();
+    for (ColumnChunkMetaData col : footer.getBlocks().get(0).getColumns()) {
+      result.put(col.getPath().toDotString(), col.getCodec());
+    }
+    return result;
+  }
+
   @Test
   public void testV2PageNullCountWithStatisticsDisabled() throws Exception {
     // Regression test: when using PARQUET_2_0 with statistics disabled on a 
nullable column,

Reply via email to