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

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 0a6e08ad24 Issue #8080 : Support Hive-style partitioning in the 
Parquet File Output transform (#8254)
0a6e08ad24 is described below

commit 0a6e08ad243f6518652e92898a3eaed9e4dd00c4
Author: vbhanuchander-lang <[email protected]>
AuthorDate: Fri Sep 4 06:26:51 2026 -0400

    Issue #8080 : Support Hive-style partitioning in the Parquet File Output 
transform (#8254)
    
    * Issue #8080 : Support Hive-style partitioning in the Parquet File Output 
transform
    
    Data partitioning was only available in Native Spark mode. The standard
    Parquet File Output transform can now partition by one or more incoming
    fields, writing the Hive-style layout that Spark's partitionBy produces:
    
      /datalake/sales/year=2026/month=08/part-00-0000-3f2a1b9c.parquet
    
    The partition fields are not written into the files, matching partitionBy
    and what a reader expects when recovering the column from the path. Nulls
    become __HIVE_DEFAULT_PARTITION__, and values are percent-escaped so a
    value containing a separator stays one folder level.
    
    Four write modes decide what happens to data already in a partition
    folder: append (unchanged behaviour), overwrite partitions, fail if
    exists, and overwrite all. Overwrite-partitions clears a folder once per
    run rather than once per file, so a reopened partition cannot delete rows
    the same run just wrote.
    
    Every open Parquet writer buffers up to a full row group, so a wide
    partition key would otherwise exhaust memory. At most "maximum open
    partitions" writers are held open and the least recently written one is
    closed when another is needed.
    
    With no partition fields configured the transform behaves exactly as
    before; the partitioned path is a separate branch in processRow.
    
    Also fixes a missing '=' in messages_en_US.properties that left the
    row group size dialog label unresolved.
    
    * Issue #8080 : Use Lombok on ParquetPartitionField and strip trailing 
backslash in partitionFolder
    
    ---------
    
    Co-authored-by: mattcasters <[email protected]>
---
 .../parquet/transforms/output/ParquetOutput.java   | 494 ++++++++++++++++++---
 .../transforms/output/ParquetOutputData.java       |  62 +++
 .../transforms/output/ParquetOutputDialog.java     | 113 +++++
 .../transforms/output/ParquetOutputMeta.java       |  86 ++++
 .../transforms/output/ParquetPartitionField.java   |  43 ++
 .../transforms/output/ParquetWriteMode.java        |  75 ++++
 .../output/messages/messages_en_US.properties      |  17 +-
 .../output/ParquetOutputPartitionTest.java         | 419 +++++++++++++++++
 8 files changed, 1252 insertions(+), 57 deletions(-)

diff --git 
a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutput.java
 
b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutput.java
index 9c7fef4f21..be3e43be82 100644
--- 
a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutput.java
+++ 
b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutput.java
@@ -17,15 +17,22 @@
 
 package org.apache.hop.parquet.transforms.output;
 
+import java.io.OutputStream;
 import java.nio.charset.StandardCharsets;
 import java.text.DecimalFormat;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Date;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
 import org.apache.avro.LogicalTypes;
 import org.apache.avro.Schema;
 import org.apache.avro.SchemaBuilder;
 import org.apache.commons.vfs2.FileObject;
+import org.apache.commons.vfs2.Selectors;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.RowMetaAndData;
@@ -49,6 +56,15 @@ import org.apache.parquet.schema.MessageType;
 
 public class ParquetOutput extends BaseTransform<ParquetOutputMeta, 
ParquetOutputData> {
 
+  /** How many partitions are written to at the same time when nothing else is 
configured. */
+  static final int DEFAULT_MAX_OPEN_PARTITIONS = 10;
+
+  /**
+   * The partition value Hive and Spark both use for a null, so that 
partitioned data written here
+   * can be read back by them.
+   */
+  static final String DEFAULT_PARTITION_NAME = "__HIVE_DEFAULT_PARTITION__";
+
   public ParquetOutput(
       TransformMeta transformMeta,
       ParquetOutputMeta meta,
@@ -73,6 +89,11 @@ public class ParquetOutput extends 
BaseTransform<ParquetOutputMeta, ParquetOutpu
         Const.toIntExpanded(
             resolve(meta.getRowGroupSize()), 
ParquetProperties.DEFAULT_PAGE_ROW_COUNT_LIMIT);
     data.maxSplitSizeRows = 
Const.toLongExpanded(resolve(meta.getFileSplitSize()), -1);
+    data.maxOpenPartitions =
+        Const.toIntExpanded(resolve(meta.getMaxOpenPartitions()), 
DEFAULT_MAX_OPEN_PARTITIONS);
+    if (data.maxOpenPartitions < 1) {
+      data.maxOpenPartitions = 1;
+    }
 
     return super.init();
   }
@@ -87,7 +108,11 @@ public class ParquetOutput extends 
BaseTransform<ParquetOutputMeta, ParquetOutpu
     }
 
     if (row == null) {
-      closeFile();
+      if (meta.isPartitioning()) {
+        closeAllPartitionWriters();
+      } else {
+        closeFile();
+      }
       setOutputDone();
       return false;
     }
@@ -95,12 +120,24 @@ public class ParquetOutput extends 
BaseTransform<ParquetOutputMeta, ParquetOutpu
     if (first) {
       first = false;
       resolveOutputFields();
-      openNewFile();
+      if (meta.isPartitioning()) {
+        data.runToken = UUID.randomUUID().toString().substring(0, 8);
+        initWriterProperties();
+        data.messageType = buildSchema();
+        data.partitionWriters = data.newPartitionWriterMap();
+        data.clearedPartitions = new HashSet<>();
+        if (meta.getWriteMode() == ParquetWriteMode.OverwriteAll) {
+          clearBaseFolder();
+        }
+      } else {
+        openNewFile();
+      }
     }
 
     // See if we don't need to create a new file split into parts...
     //
-    if (meta.isFilenameIncludingSplitNr()
+    if (!meta.isPartitioning()
+        && meta.isFilenameIncludingSplitNr()
         && data.maxSplitSizeRows > 0
         && data.splitRowCount >= data.maxSplitSizeRows) {
       // Close file and start a new one...
@@ -145,9 +182,16 @@ public class ParquetOutput extends 
BaseTransform<ParquetOutputMeta, ParquetOutpu
         }
       }
 
-      data.writer.write(new RowMetaAndData(parquetRowMeta, parquetRow));
+      if (meta.isPartitioning()) {
+        ParquetOutputData.PartitionWriter partitionWriter =
+            getPartitionWriter(partitionPath(getInputRowMeta(), row));
+        partitionWriter.writer.write(new RowMetaAndData(parquetRowMeta, 
parquetRow));
+        partitionWriter.rowCount++;
+      } else {
+        data.writer.write(new RowMetaAndData(parquetRowMeta, parquetRow));
+        data.splitRowCount++;
+      }
       incrementLinesOutput();
-      data.splitRowCount++;
     } catch (Exception e) {
       throw new HopException("Error writing row to parquet file", e);
     }
@@ -160,12 +204,60 @@ public class ParquetOutput extends 
BaseTransform<ParquetOutputMeta, ParquetOutpu
     data.splitRowCount = 0;
     data.split++;
 
-    // Hadoop configuration
+    initWriterProperties();
+
+    MessageType messageType = buildSchema();
+
+    // Calculate the filename...
     //
+    data.filename = buildFilename(getPipeline().getExecutionStartDate());
+
+    try {
+      FileObject fileObject = HopVfs.getFileObject(data.filename, variables);
+
+      // See if we need to create the parent folder(s)...
+      //
+      if (meta.isFilenameCreatingParentFolders()) {
+        FileObject parentFolder = fileObject.getParent();
+        if (parentFolder != null && !parentFolder.exists()) {
+          // Try to create the parent folder...
+          //
+          parentFolder.createFolder();
+        }
+      }
+
+      data.outputStream = HopVfs.getOutputStream(data.filename, false, 
variables);
+      data.countingStream = new CountingOutputStream(data.outputStream);
+      data.outputFile = new ParquetOutputFile(data.countingStream);
+
+      data.writer =
+          new ParquetWriterBuilder(
+                  messageType,
+                  data.avroSchema,
+                  data.outputFile,
+                  data.sourceFieldIndexes,
+                  data.outputFields)
+              .withPageSize(data.pageSize)
+              .withDictionaryPageSize(data.dictionaryPageSize)
+              .withValidation(ParquetWriter.DEFAULT_IS_VALIDATING_ENABLED)
+              .withCompressionCodec(meta.getCompressionCodec())
+              .withRowGroupSize(data.rowGroupSize)
+              .withWriterVersion(data.props.getWriterVersion())
+              .withWriteMode(ParquetFileWriter.Mode.CREATE)
+              .build();
+
+    } catch (Exception e) {
+      throw new HopException("Unable to create output file '" + data.filename 
+ "'", e);
+    }
+  }
+
+  /**
+   * Sets up the Hadoop configuration and Parquet properties. Both the 
single-file and the
+   * partitioned paths need these before a writer can be built.
+   */
+  private void initWriterProperties() {
     data.conf = new Configuration();
 
-    // Parquet Properties
-    //
     ParquetProperties.Builder builder = ParquetProperties.builder();
     builder =
         switch (meta.getVersion()) {
@@ -173,7 +265,14 @@ public class ParquetOutput extends 
BaseTransform<ParquetOutputMeta, ParquetOutpu
           case Version2 -> 
builder.withWriterVersion(ParquetProperties.WriterVersion.PARQUET_2_0);
         };
     data.props = builder.build();
+  }
 
+  /**
+   * Builds the Avro schema for the resolved output fields and converts it to 
a Parquet schema. Kept
+   * separate from opening a file so the partitioned path can build it once 
and reuse it for every
+   * partition.
+   */
+  private MessageType buildSchema() throws HopException {
     SchemaBuilder.FieldAssembler<Schema> fieldAssembler =
         SchemaBuilder.record("ApacheHopParquetSchema").fields();
 
@@ -225,62 +324,28 @@ public class ParquetOutput extends 
BaseTransform<ParquetOutputMeta, ParquetOutpu
 
     // Convert from Avro to Parquet schema
     //
-    MessageType messageType = new 
AvroSchemaConverter().convert(data.avroSchema);
-
-    // Calculate the filename...
-    //
-    data.filename = buildFilename(getPipeline().getExecutionStartDate());
-
-    try {
-      FileObject fileObject = HopVfs.getFileObject(data.filename, variables);
-
-      // See if we need to create the parent folder(s)...
-      //
-      if (meta.isFilenameCreatingParentFolders()) {
-        FileObject parentFolder = fileObject.getParent();
-        if (parentFolder != null && !parentFolder.exists()) {
-          // Try to create the parent folder...
-          //
-          parentFolder.createFolder();
-        }
-      }
-
-      data.outputStream = HopVfs.getOutputStream(data.filename, false, 
variables);
-      data.countingStream = new CountingOutputStream(data.outputStream);
-      data.outputFile = new ParquetOutputFile(data.countingStream);
-
-      data.writer =
-          new ParquetWriterBuilder(
-                  messageType,
-                  data.avroSchema,
-                  data.outputFile,
-                  data.sourceFieldIndexes,
-                  data.outputFields)
-              .withPageSize(data.pageSize)
-              .withDictionaryPageSize(data.dictionaryPageSize)
-              .withValidation(ParquetWriter.DEFAULT_IS_VALIDATING_ENABLED)
-              .withCompressionCodec(meta.getCompressionCodec())
-              .withRowGroupSize(data.rowGroupSize)
-              .withWriterVersion(data.props.getWriterVersion())
-              .withWriteMode(ParquetFileWriter.Mode.CREATE)
-              .build();
-
-    } catch (Exception e) {
-      throw new HopException("Unable to create output file '" + data.filename 
+ "'", e);
-    }
+    return new AvroSchemaConverter().convert(data.avroSchema);
   }
 
   void resolveOutputFields() throws HopException {
     data.outputFields = new ArrayList<>();
     data.sourceFieldIndexes = new ArrayList<>();
+    resolvePartitionFields();
 
     if (meta.getFields() == null || meta.getFields().isEmpty()) {
       IRowMeta inputRowMeta = getInputRowMeta();
       for (int i = 0; i < inputRowMeta.size(); i++) {
+        if (data.partitionFieldIndexes.contains(i)) {
+          // The value lives in the folder name, so it is not written into the 
file. This is what
+          // Spark's partitionBy() does, and what a reader expects when it 
recovers the column from
+          // the path.
+          continue;
+        }
         String fieldName = inputRowMeta.getValueMeta(i).getName();
         data.outputFields.add(new ParquetField(fieldName, fieldName));
         data.sourceFieldIndexes.add(i);
       }
+      verifyFieldsRemain();
       return;
     }
 
@@ -289,10 +354,44 @@ public class ParquetOutput extends 
BaseTransform<ParquetOutputMeta, ParquetOutpu
       if (index < 0) {
         throw new HopException("Unable to find source field '" + 
field.getSourceFieldName() + "'");
       }
+      if (data.partitionFieldIndexes.contains(index)) {
+        continue;
+      }
       String targetFieldName = Const.NVL(field.getTargetFieldName(), 
field.getSourceFieldName());
       data.outputFields.add(new ParquetField(field.getSourceFieldName(), 
targetFieldName));
       data.sourceFieldIndexes.add(index);
     }
+    verifyFieldsRemain();
+  }
+
+  /** Resolves the configured partition field names to indexes in the incoming 
row. */
+  private void resolvePartitionFields() throws HopException {
+    data.partitionFieldIndexes = new ArrayList<>();
+    if (!meta.isPartitioning()) {
+      return;
+    }
+    for (ParquetPartitionField field : meta.getPartitionFields()) {
+      String name = field.getName();
+      if (name == null || name.trim().isEmpty()) {
+        continue;
+      }
+      int index = getInputRowMeta().indexOfValue(name);
+      if (index < 0) {
+        throw new HopException("Unable to find partition field '" + name + "' 
in the input");
+      }
+      if (data.partitionFieldIndexes.contains(index)) {
+        throw new HopException("Partition field '" + name + "' is listed more 
than once");
+      }
+      data.partitionFieldIndexes.add(index);
+    }
+  }
+
+  private void verifyFieldsRemain() throws HopException {
+    if (data.outputFields.isEmpty()) {
+      throw new HopException(
+          "Every output field is a partition field, which would leave nothing 
to write into the "
+              + "Parquet files. Leave at least one non-partition field.");
+    }
   }
 
   String buildFilename(Date date) {
@@ -330,6 +429,285 @@ public class ParquetOutput extends 
BaseTransform<ParquetOutputMeta, ParquetOutpu
     return filename;
   }
 
+  /**
+   * Builds the {@code name=value/...} folder path for a row, in the 
configured partition field
+   * order. Nulls become {@link #DEFAULT_PARTITION_NAME} and characters that 
would break the path
+   * are percent-escaped, both so the result can be read back by Hive and 
Spark.
+   */
+  String partitionPath(IRowMeta rowMeta, Object[] row) throws HopException {
+    StringBuilder path = new StringBuilder();
+    for (int i = 0; i < data.partitionFieldIndexes.size(); i++) {
+      int index = data.partitionFieldIndexes.get(i);
+      String value = rowMeta.getString(row, index);
+      if (i > 0) {
+        path.append('/');
+      }
+      path.append(escapePathValue(rowMeta.getValueMeta(index).getName()))
+          .append('=')
+          .append(
+              value == null || value.isEmpty() ? DEFAULT_PARTITION_NAME : 
escapePathValue(value));
+    }
+    return path.toString();
+  }
+
+  /** Percent-escapes the characters that cannot appear in a partition folder 
name. */
+  static String escapePathValue(String value) {
+    StringBuilder escaped = new StringBuilder(value.length());
+    for (int i = 0; i < value.length(); i++) {
+      char c = value.charAt(i);
+      boolean needsEscape =
+          c == '%' || c == '=' || c == '/' || c == '\\' || c == ':' || c == 
'"' || c == '\''
+              || c < 0x20 || c == 0x7f;
+      if (needsEscape) {
+        escaped.append('%').append(String.format("%02X", (int) c));
+      } else {
+        escaped.append(c);
+      }
+    }
+    return escaped.toString();
+  }
+
+  /**
+   * Returns the open writer for a partition, opening one if needed. When too 
many partitions are
+   * open at once the least recently written one is closed first, so a wide 
partition key cannot run
+   * the transform out of memory: every open Parquet writer buffers up to a 
full row group.
+   */
+  private ParquetOutputData.PartitionWriter getPartitionWriter(String 
partitionPath)
+      throws HopException {
+    ParquetOutputData.PartitionWriter partitionWriter = 
data.partitionWriters.get(partitionPath);
+
+    if (partitionWriter != null
+        && meta.isFilenameIncludingSplitNr()
+        && data.maxSplitSizeRows > 0
+        && partitionWriter.rowCount >= data.maxSplitSizeRows) {
+      // This partition's current file is full, roll over to the next part.
+      closePartitionWriter(partitionPath, partitionWriter);
+      data.partitionWriters.remove(partitionPath);
+      partitionWriter = null;
+    }
+
+    if (partitionWriter != null) {
+      return partitionWriter;
+    }
+
+    while (data.partitionWriters.size() >= data.maxOpenPartitions) {
+      Iterator<Map.Entry<String, ParquetOutputData.PartitionWriter>> iterator =
+          data.partitionWriters.entrySet().iterator();
+      Map.Entry<String, ParquetOutputData.PartitionWriter> oldest = 
iterator.next();
+      closePartitionWriter(oldest.getKey(), oldest.getValue());
+      iterator.remove();
+    }
+
+    partitionWriter = openPartitionWriter(partitionPath);
+    data.partitionWriters.put(partitionPath, partitionWriter);
+    return partitionWriter;
+  }
+
+  private ParquetOutputData.PartitionWriter openPartitionWriter(String 
partitionPath)
+      throws HopException {
+    String folder = partitionFolder(partitionPath);
+    applyWriteMode(partitionPath, folder);
+
+    String filename = buildPartitionFilename(folder, 
getPipeline().getExecutionStartDate());
+    try {
+      FileObject fileObject = HopVfs.getFileObject(filename, variables);
+      FileObject parentFolder = fileObject.getParent();
+      if (parentFolder != null && !parentFolder.exists()) {
+        // Partition folders are created regardless of the "create parent 
folders" option: the
+        // layout is what the transform was asked to produce, not something 
the user typed.
+        parentFolder.createFolder();
+      }
+
+      OutputStream outputStream = HopVfs.getOutputStream(filename, false, 
variables);
+      CountingOutputStream countingStream = new 
CountingOutputStream(outputStream);
+      ParquetOutputFile outputFile = new ParquetOutputFile(countingStream);
+
+      ParquetWriter<RowMetaAndData> writer =
+          new ParquetWriterBuilder(
+                  data.messageType,
+                  data.avroSchema,
+                  outputFile,
+                  data.sourceFieldIndexes,
+                  data.outputFields)
+              .withPageSize(data.pageSize)
+              .withDictionaryPageSize(data.dictionaryPageSize)
+              .withValidation(ParquetWriter.DEFAULT_IS_VALIDATING_ENABLED)
+              .withCompressionCodec(meta.getCompressionCodec())
+              .withRowGroupSize(data.rowGroupSize)
+              .withWriterVersion(data.props.getWriterVersion())
+              .withWriteMode(ParquetFileWriter.Mode.CREATE)
+              .build();
+
+      if (isDetailed()) {
+        logDetailed("Opened partition file '" + filename + "'");
+      }
+      return new ParquetOutputData.PartitionWriter(
+          filename, outputStream, countingStream, outputFile, writer);
+    } catch (Exception e) {
+      throw new HopException("Unable to create partition output file '" + 
filename + "'", e);
+    }
+  }
+
+  /** The folder a partition's files go into: the base folder plus the 
partition path. */
+  String partitionFolder(String partitionPath) {
+    String base = resolve(meta.getFilenameBase());
+    if (base.endsWith("/") || base.endsWith("\\")) {
+      base = base.substring(0, base.length() - 1);
+    }
+    return base + "/" + toVfsPath(partitionPath);
+  }
+
+  /**
+   * HopVfs resolves a path as a URI and percent-decodes it, so a {@code %2F} 
we wrote for a value
+   * containing a separator would come back as a real folder level. Encoding 
our own {@code %} as
+   * {@code %25} means VFS decodes it back to a literal {@code %}, leaving the 
Hive-style {@code
+   * name=EU%2FWest} on disk for a reader to decode.
+   */
+  static String toVfsPath(String partitionPath) {
+    return partitionPath.replace("%", "%25");
+  }
+
+  /**
+   * Names a file inside a partition folder. The base name is not repeated, 
since the folder already
+   * identifies the data; the copy number and a per-run sequence keep parallel 
copies and re-opened
+   * partitions from colliding.
+   */
+  private String buildPartitionFilename(String folder, Date date) {
+    StringBuilder filename = new StringBuilder(folder).append("/part");
+    if (meta.isFilenameIncludingDate()) {
+      filename.append('-').append(new 
SimpleDateFormat("yyyyMMdd").format(date));
+    }
+    if (meta.isFilenameIncludingTime()) {
+      filename.append('-').append(new SimpleDateFormat("HHmmss").format(date));
+    }
+    if (meta.isFilenameIncludingDateTime()) {
+      filename
+          .append('-')
+          .append(new 
SimpleDateFormat(resolve(meta.getFilenameDateTimeFormat())).format(date));
+    }
+    filename.append('-').append(new DecimalFormat("00").format(getCopyNr()));
+    filename.append('-').append(new 
DecimalFormat("0000").format(data.partitionFileNr++));
+    // Without something unique per run, a second run in append mode would 
write the same
+    // part name and silently replace the first run's file.
+    filename.append('-').append(data.runToken);
+    if (data.isBeamContext()) {
+      
filename.append('_').append(getLogChannelId()).append('_').append(data.getBeamBundleNr());
+    }
+    String extension = Const.NVL(resolve(meta.getFilenameExtension()), 
"parquet");
+    String compressionExtension = meta.getCompressionCodec().getExtension();
+    if (meta.isFilenameCompressionBeforeExtension()) {
+      filename.append(compressionExtension).append('.').append(extension);
+    } else {
+      filename.append('.').append(extension).append(compressionExtension);
+    }
+    return filename.toString();
+  }
+
+  /**
+   * Applies the configured write mode the first time this run touches a 
partition folder. The
+   * overwrite-all mode is handled once up front, before any file is opened.
+   */
+  private void applyWriteMode(String partitionPath, String folder) throws 
HopException {
+    ParquetWriteMode mode = meta.getWriteMode();
+    if (mode == null || mode == ParquetWriteMode.Append || mode == 
ParquetWriteMode.OverwriteAll) {
+      return;
+    }
+    if (data.clearedPartitions.contains(partitionPath)) {
+      // Already dealt with; a re-opened partition must not wipe what this run 
just wrote.
+      return;
+    }
+    data.clearedPartitions.add(partitionPath);
+
+    try {
+      FileObject folderObject = HopVfs.getFileObject(folder, variables);
+      if (!folderObject.exists()) {
+        return;
+      }
+      if (mode == ParquetWriteMode.FailIfExists) {
+        throw new HopException("Partition folder '" + folder + "' already 
exists");
+      }
+      // OverwritePartitions
+      int deleted = folderObject.delete(Selectors.EXCLUDE_SELF);
+      if (isDetailed()) {
+        logDetailed("Emptied partition folder '" + folder + "', removed " + 
deleted + " file(s)");
+      }
+    } catch (HopException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new HopException("Unable to apply write mode to partition folder 
'" + folder + "'", e);
+    }
+  }
+
+  /** Empties the base folder once, for the overwrite-all mode. */
+  private void clearBaseFolder() throws HopException {
+    if (data.baseFolderCleared) {
+      return;
+    }
+    data.baseFolderCleared = true;
+    String base = partitionFolder("");
+    try {
+      FileObject folderObject = HopVfs.getFileObject(base, variables);
+      if (folderObject.exists()) {
+        int deleted = folderObject.delete(Selectors.EXCLUDE_SELF);
+        logBasic("Emptied output folder '" + base + "', removed " + deleted + 
" file(s)");
+      }
+    } catch (Exception e) {
+      throw new HopException("Unable to empty output folder '" + base + "'", 
e);
+    }
+  }
+
+  private void closeAllPartitionWriters() throws HopException {
+    if (data.partitionWriters == null) {
+      return;
+    }
+    // Copy first: closing reports lineage, and the map must not be mutated 
while iterating.
+    List<Map.Entry<String, ParquetOutputData.PartitionWriter>> open =
+        new ArrayList<>(data.partitionWriters.entrySet());
+    data.partitionWriters.clear();
+    HopException firstFailure = null;
+    for (Map.Entry<String, ParquetOutputData.PartitionWriter> entry : open) {
+      try {
+        closePartitionWriter(entry.getKey(), entry.getValue());
+      } catch (HopException e) {
+        // Keep closing the rest so no file is left half-written, then report 
the first failure.
+        if (firstFailure == null) {
+          firstFailure = e;
+        }
+      }
+    }
+    if (firstFailure != null) {
+      throw firstFailure;
+    }
+  }
+
+  private void closePartitionWriter(
+      String partitionPath, ParquetOutputData.PartitionWriter partitionWriter) 
throws HopException {
+    try {
+      partitionWriter.writer.close();
+      if (partitionWriter.countingStream != null) {
+        long written = partitionWriter.countingStream.getCount();
+        dataVolumeOut = (dataVolumeOut != null ? dataVolumeOut : 0L) + written;
+        if (!data.isBeamContext() && written > 0) {
+          try {
+            FileObject outFile = 
HopVfs.getFileObject(partitionWriter.filename, variables);
+            LineageFileIoEmitter.emitTransformFileIo(
+                this, FileIoOperation.WRITE, null, outFile, written, true, 
null);
+          } catch (Exception ignored) {
+            // optional lineage
+          }
+        }
+      }
+    } catch (Exception e) {
+      throw new HopException(
+          "Error closing file "
+              + partitionWriter.filename
+              + " for partition '"
+              + partitionPath
+              + "'",
+          e);
+    }
+  }
+
   private void closeFile() throws HopException {
     try {
       data.writer.close();
@@ -354,19 +732,27 @@ public class ParquetOutput extends 
BaseTransform<ParquetOutputMeta, ParquetOutpu
   @Override
   public void batchComplete() throws HopException {
     if (!data.isBeamContext()) {
-      closeFile();
+      if (meta.isPartitioning()) {
+        closeAllPartitionWriters();
+      } else {
+        closeFile();
+      }
     }
   }
 
   @Override
   public void startBundle() throws HopException {
-    if (!first) {
+    if (!first && !meta.isPartitioning()) {
       openNewFile();
     }
   }
 
   @Override
   public void finishBundle() throws HopException {
-    closeFile();
+    if (meta.isPartitioning()) {
+      closeAllPartitionWriters();
+    } else {
+      closeFile();
+    }
   }
 }
diff --git 
a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputData.java
 
b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputData.java
index ce3573cc72..cb58751034 100644
--- 
a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputData.java
+++ 
b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputData.java
@@ -18,7 +18,10 @@
 package org.apache.hop.parquet.transforms.output;
 
 import java.io.OutputStream;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.apache.avro.Schema;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hop.core.RowMetaAndData;
@@ -27,6 +30,7 @@ import org.apache.hop.pipeline.transform.BaseTransformData;
 import org.apache.hop.pipeline.transform.ITransformData;
 import org.apache.parquet.column.ParquetProperties;
 import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.schema.MessageType;
 
 @SuppressWarnings("java:S1104")
 public class ParquetOutputData extends BaseTransformData implements 
ITransformData {
@@ -47,7 +51,65 @@ public class ParquetOutputData extends BaseTransformData 
implements ITransformDa
   public int dictionaryPageSize;
   public Schema avroSchema;
 
+  /** The Parquet schema, built once and shared by every partition file. */
+  public MessageType messageType;
+
+  /** Indexes of the input fields the output is partitioned by, in the 
configured order. */
+  public List<Integer> partitionFieldIndexes;
+
+  /**
+   * The writer per partition folder, keyed by the relative {@code 
name=value/...} path. Access
+   * order is maintained so the least recently written partition can be closed 
first when {@link
+   * #maxOpenPartitions} is reached.
+   */
+  public Map<String, PartitionWriter> partitionWriters;
+
+  /** Partition folders already emptied during this run, for the 
overwrite-partitions mode. */
+  public Set<String> clearedPartitions;
+
+  /** Whether the base folder has been emptied yet, for the overwrite-all 
mode. */
+  public boolean baseFolderCleared;
+
+  /** How many partitions may be written to at the same time. */
+  public int maxOpenPartitions;
+
+  /** Number of partition files opened so far, used to keep their names 
unique. */
+  public int partitionFileNr;
+
+  /** Short token unique to this run, so a later run does not overwrite this 
one's files. */
+  public String runToken;
+
   public ParquetOutputData() {
     super();
   }
+
+  /** Everything needed to write, measure and close one partition folder's 
current file. */
+  public static class PartitionWriter {
+    public String filename;
+    public OutputStream outputStream;
+    public CountingOutputStream countingStream;
+    public ParquetOutputFile outputFile;
+    public ParquetWriter<RowMetaAndData> writer;
+    public long rowCount;
+
+    public PartitionWriter(
+        String filename,
+        OutputStream outputStream,
+        CountingOutputStream countingStream,
+        ParquetOutputFile outputFile,
+        ParquetWriter<RowMetaAndData> writer) {
+      this.filename = filename;
+      this.outputStream = outputStream;
+      this.countingStream = countingStream;
+      this.outputFile = outputFile;
+      this.writer = writer;
+    }
+  }
+
+  /**
+   * Creates the partition writer map with access-order iteration for 
least-recently-used eviction.
+   */
+  public Map<String, PartitionWriter> newPartitionWriterMap() {
+    return new LinkedHashMap<>(16, 0.75f, true);
+  }
 }
diff --git 
a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputDialog.java
 
b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputDialog.java
index fd6d570618..91869d3b39 100644
--- 
a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputDialog.java
+++ 
b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputDialog.java
@@ -69,6 +69,11 @@ public class ParquetOutputDialog extends BaseTransformDialog 
{
   private TextVar wDataPageSize;
   private TextVar wDictionaryPageSize;
   private TableView wFields;
+  private TableView wPartitionFields;
+  private Combo wWriteMode;
+  private TextVar wMaxOpenPartitions;
+  private Label wlWriteMode;
+  private Label wlMaxOpenPartitions;
 
   private String returnValue;
 
@@ -424,6 +429,87 @@ public class ParquetOutputDialog extends 
BaseTransformDialog {
     wDictionaryPageSize.setLayoutData(fdDictionaryPageSize);
     lastControl = wDictionaryPageSize;
 
+    Label wlPartitionFields = new Label(shell, SWT.LEFT);
+    wlPartitionFields.setText(
+        BaseMessages.getString(PKG, 
"ParquetOutputDialog.PartitionFields.Label"));
+    PropsUi.setLook(wlPartitionFields);
+    FormData fdlPartitionFields = new FormData();
+    fdlPartitionFields.left = new FormAttachment(0, 0);
+    fdlPartitionFields.right = new FormAttachment(100, 0);
+    fdlPartitionFields.top = new FormAttachment(lastControl, margin);
+    wlPartitionFields.setLayoutData(fdlPartitionFields);
+
+    ColumnInfo[] partitionColumns =
+        new ColumnInfo[] {
+          new ColumnInfo(
+              BaseMessages.getString(PKG, 
"ParquetOutputDialog.PartitionFieldsColumn.Field.Label"),
+              ColumnInfo.COLUMN_TYPE_CCOMBO,
+              new String[0]),
+        };
+    wPartitionFields =
+        new TableView(
+            variables,
+            shell,
+            SWT.BORDER,
+            partitionColumns,
+            input.getPartitionFields().size(),
+            false,
+            null,
+            props);
+    PropsUi.setLook(wPartitionFields);
+    FormData fdPartitionFields = new FormData();
+    fdPartitionFields.left = new FormAttachment(0, 0);
+    fdPartitionFields.top = new FormAttachment(wlPartitionFields, margin);
+    fdPartitionFields.right = new FormAttachment(100, 0);
+    fdPartitionFields.height = (int) (100 * props.getZoomFactor());
+    wPartitionFields.setLayoutData(fdPartitionFields);
+    wPartitionFields.addModifyListener(e -> enableFields());
+    lastControl = wPartitionFields;
+
+    wlWriteMode = new Label(shell, SWT.RIGHT);
+    wlWriteMode.setText(BaseMessages.getString(PKG, 
"ParquetOutputDialog.WriteMode.Label"));
+    wlWriteMode.setToolTipText(
+        BaseMessages.getString(PKG, "ParquetOutputDialog.WriteMode.ToolTip"));
+    PropsUi.setLook(wlWriteMode);
+    FormData fdlWriteMode = new FormData();
+    fdlWriteMode.left = new FormAttachment(0, 0);
+    fdlWriteMode.right = new FormAttachment(middle, -margin);
+    fdlWriteMode.top = new FormAttachment(lastControl, margin);
+    wlWriteMode.setLayoutData(fdlWriteMode);
+    wWriteMode = new Combo(shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER | 
SWT.READ_ONLY);
+    wWriteMode.setItems(ParquetWriteMode.getDescriptions());
+    wWriteMode.setToolTipText(BaseMessages.getString(PKG, 
"ParquetOutputDialog.WriteMode.ToolTip"));
+    PropsUi.setLook(wWriteMode);
+    FormData fdWriteMode = new FormData();
+    fdWriteMode.left = new FormAttachment(middle, 0);
+    fdWriteMode.top = new FormAttachment(wlWriteMode, 0, SWT.CENTER);
+    fdWriteMode.right = new FormAttachment(100, 0);
+    wWriteMode.setLayoutData(fdWriteMode);
+    lastControl = wWriteMode;
+
+    wlMaxOpenPartitions = new Label(shell, SWT.RIGHT);
+    wlMaxOpenPartitions.setText(
+        BaseMessages.getString(PKG, 
"ParquetOutputDialog.MaxOpenPartitions.Label"));
+    wlMaxOpenPartitions.setToolTipText(
+        BaseMessages.getString(PKG, 
"ParquetOutputDialog.MaxOpenPartitions.ToolTip"));
+    PropsUi.setLook(wlMaxOpenPartitions);
+    FormData fdlMaxOpenPartitions = new FormData();
+    fdlMaxOpenPartitions.left = new FormAttachment(0, 0);
+    fdlMaxOpenPartitions.right = new FormAttachment(middle, -margin);
+    fdlMaxOpenPartitions.top = new FormAttachment(lastControl, margin);
+    wlMaxOpenPartitions.setLayoutData(fdlMaxOpenPartitions);
+    wMaxOpenPartitions = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | 
SWT.BORDER);
+    wMaxOpenPartitions.enableExpandedInteger();
+    wMaxOpenPartitions.setToolTipText(
+        BaseMessages.getString(PKG, 
"ParquetOutputDialog.MaxOpenPartitions.ToolTip"));
+    PropsUi.setLook(wMaxOpenPartitions);
+    FormData fdMaxOpenPartitions = new FormData();
+    fdMaxOpenPartitions.left = new FormAttachment(middle, 0);
+    fdMaxOpenPartitions.top = new FormAttachment(wlMaxOpenPartitions, 0, 
SWT.CENTER);
+    fdMaxOpenPartitions.right = new FormAttachment(100, 0);
+    wMaxOpenPartitions.setLayoutData(fdMaxOpenPartitions);
+    lastControl = wMaxOpenPartitions;
+
     Label wlFields = new Label(shell, SWT.LEFT);
     wlFields.setText(BaseMessages.getString(PKG, 
"ParquetOutputDialog.Fields.Label"));
     PropsUi.setLook(wlFields);
@@ -468,6 +554,13 @@ public class ParquetOutputDialog extends 
BaseTransformDialog {
 
     wlFilenameSplitSize.setEnabled(wFilenameIncludeSplitNr.getSelection());
     wFilenameSplitSize.setEnabled(wFilenameIncludeSplitNr.getSelection());
+
+    // The write mode and the open-partition limit only mean something while 
partitioning.
+    boolean partitioning = !wPartitionFields.getNonEmptyItems().isEmpty();
+    wlWriteMode.setEnabled(partitioning);
+    wWriteMode.setEnabled(partitioning);
+    wlMaxOpenPartitions.setEnabled(partitioning);
+    wMaxOpenPartitions.setEnabled(partitioning);
   }
 
   private void getFields() {
@@ -486,6 +579,7 @@ public class ParquetOutputDialog extends 
BaseTransformDialog {
     try {
       IRowMeta fields = pipelineMeta.getPrevTransformFields(variables, 
transformName);
       wFields.getColumns()[0].setComboValues(fields.getFieldNames());
+      wPartitionFields.getColumns()[0].setComboValues(fields.getFieldNames());
     } catch (Exception e) {
       LogChannel.UI.logError("Error getting source fields", e);
     }
@@ -512,6 +606,19 @@ public class ParquetOutputDialog extends 
BaseTransformDialog {
       item.setText(2, Const.NVL(field.getTargetFieldName(), ""));
     }
     wFields.optimizeTableView();
+
+    for (int i = 0; i < input.getPartitionFields().size(); i++) {
+      ParquetPartitionField field = input.getPartitionFields().get(i);
+      TableItem item = wPartitionFields.table.getItem(i);
+      item.setText(1, Const.NVL(field.getName(), ""));
+    }
+    wPartitionFields.optimizeTableView();
+    wWriteMode.setText(
+        input.getWriteMode() == null
+            ? ParquetWriteMode.Append.getDescription()
+            : input.getWriteMode().getDescription());
+    wMaxOpenPartitions.setText(Const.NVL(input.getMaxOpenPartitions(), ""));
+
     enableFields();
   }
 
@@ -546,6 +653,12 @@ public class ParquetOutputDialog extends 
BaseTransformDialog {
     for (TableItem item : wFields.getNonEmptyItems()) {
       input.getFields().add(new ParquetField(item.getText(1), 
item.getText(2)));
     }
+    input.getPartitionFields().clear();
+    for (TableItem item : wPartitionFields.getNonEmptyItems()) {
+      input.getPartitionFields().add(new 
ParquetPartitionField(item.getText(1)));
+    }
+    
input.setWriteMode(ParquetWriteMode.getModeFromDescription(wWriteMode.getText()));
+    input.setMaxOpenPartitions(wMaxOpenPartitions.getText());
     input.setChanged();
     dispose();
   }
diff --git 
a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputMeta.java
 
b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputMeta.java
index 2cfb84df76..a523a40931 100644
--- 
a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputMeta.java
+++ 
b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputMeta.java
@@ -92,6 +92,15 @@ public class ParquetOutputMeta extends 
BaseTransformMeta<ParquetOutput, ParquetO
   @HopMetadataProperty(groupKey = "fields", key = "field")
   private List<ParquetField> fields;
 
+  @HopMetadataProperty(groupKey = "partition_fields", key = "partition_field")
+  private List<ParquetPartitionField> partitionFields;
+
+  @HopMetadataProperty(key = "write_mode", storeWithCode = true)
+  private ParquetWriteMode writeMode;
+
+  @HopMetadataProperty(key = "max_open_partitions")
+  private String maxOpenPartitions;
+
   public ParquetOutputMeta() {
     filenameDateTimeFormat = "yyyyMMdd-HHmmss";
     compressionCodec = CompressionCodecName.UNCOMPRESSED;
@@ -100,6 +109,9 @@ public class ParquetOutputMeta extends 
BaseTransformMeta<ParquetOutput, ParquetO
     dataPageSize = Integer.toString(8192);
     dictionaryPageSize = 
Integer.toString(ParquetProperties.DEFAULT_DICTIONARY_PAGE_SIZE);
     fields = new ArrayList<>();
+    partitionFields = new ArrayList<>();
+    writeMode = ParquetWriteMode.Append;
+    maxOpenPartitions = "10";
     filenameIncludingCopyNr = true;
     filenameIncludingSplitNr = true;
     filenameCreatingParentFolders = true;
@@ -125,6 +137,14 @@ public class ParquetOutputMeta extends 
BaseTransformMeta<ParquetOutput, ParquetO
     this.dataPageSize = m.dataPageSize;
     this.dictionaryPageSize = m.dictionaryPageSize;
     this.fields = m.fields;
+    this.partitionFields = new ArrayList<>();
+    if (m.partitionFields != null) {
+      for (ParquetPartitionField f : m.partitionFields) {
+        this.partitionFields.add(new ParquetPartitionField(f));
+      }
+    }
+    this.writeMode = m.writeMode;
+    this.maxOpenPartitions = m.maxOpenPartitions;
   }
 
   /**
@@ -398,4 +418,70 @@ public class ParquetOutputMeta extends 
BaseTransformMeta<ParquetOutput, ParquetO
   public void setFields(List<ParquetField> fields) {
     this.fields = fields;
   }
+
+  /**
+   * Gets partitionFields
+   *
+   * @return value of partitionFields
+   */
+  public List<ParquetPartitionField> getPartitionFields() {
+    return partitionFields;
+  }
+
+  /**
+   * @param partitionFields The partitionFields to set
+   */
+  public void setPartitionFields(List<ParquetPartitionField> partitionFields) {
+    this.partitionFields = partitionFields;
+  }
+
+  /**
+   * Whether the transform partitions its output. Only then do the write mode 
and the maximum number
+   * of open partitions have any effect.
+   *
+   * @return true if at least one partition field is configured
+   */
+  public boolean isPartitioning() {
+    if (partitionFields == null) {
+      return false;
+    }
+    for (ParquetPartitionField field : partitionFields) {
+      if (field.getName() != null && !field.getName().trim().isEmpty()) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Gets writeMode
+   *
+   * @return value of writeMode
+   */
+  public ParquetWriteMode getWriteMode() {
+    return writeMode;
+  }
+
+  /**
+   * @param writeMode The writeMode to set
+   */
+  public void setWriteMode(ParquetWriteMode writeMode) {
+    this.writeMode = writeMode;
+  }
+
+  /**
+   * Gets maxOpenPartitions
+   *
+   * @return value of maxOpenPartitions
+   */
+  public String getMaxOpenPartitions() {
+    return maxOpenPartitions;
+  }
+
+  /**
+   * @param maxOpenPartitions The maxOpenPartitions to set
+   */
+  public void setMaxOpenPartitions(String maxOpenPartitions) {
+    this.maxOpenPartitions = maxOpenPartitions;
+  }
 }
diff --git 
a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetPartitionField.java
 
b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetPartitionField.java
new file mode 100644
index 0000000000..687f3b4634
--- /dev/null
+++ 
b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetPartitionField.java
@@ -0,0 +1,43 @@
+/*
+ * 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.hop.parquet.transforms.output;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+
+/**
+ * An incoming field to partition the output by. The field's value becomes a 
{@code name=value}
+ * directory level in the Hive-style layout, and the field itself is not 
written into the Parquet
+ * files.
+ */
+@Getter
+@Setter
+public class ParquetPartitionField {
+  @HopMetadataProperty(key = "name")
+  private String name;
+
+  public ParquetPartitionField() {}
+
+  public ParquetPartitionField(String name) {
+    this.name = name;
+  }
+
+  public ParquetPartitionField(ParquetPartitionField f) {
+    this(f.name);
+  }
+}
diff --git 
a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetWriteMode.java
 
b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetWriteMode.java
new file mode 100644
index 0000000000..bb7a45aacd
--- /dev/null
+++ 
b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetWriteMode.java
@@ -0,0 +1,75 @@
+/*
+ * 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.hop.parquet.transforms.output;
+
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IEnumHasCode;
+
+/**
+ * What to do with data that is already present in a partition folder. Only 
applies when the
+ * transform is partitioning its output; without partition fields the 
transform writes a single file
+ * as before.
+ */
+public enum ParquetWriteMode implements IEnumHasCode {
+  /** Leave existing files in place and add new ones. This is the 
pre-partitioning behaviour. */
+  Append("append", "ParquetOutput.WriteMode.Append"),
+
+  /** Empty a partition folder before the first file of this run is written 
into it. */
+  OverwritePartitions("overwrite_partitions", 
"ParquetOutput.WriteMode.OverwritePartitions"),
+
+  /** Fail if a partition folder already exists. */
+  FailIfExists("fail_if_exists", "ParquetOutput.WriteMode.FailIfExists"),
+
+  /** Empty the whole base folder once, before the first file of this run is 
written. */
+  OverwriteAll("overwrite_all", "ParquetOutput.WriteMode.OverwriteAll");
+
+  private static final Class<?> PKG = ParquetOutputMeta.class;
+
+  private final String code;
+  private final String descriptionKey;
+
+  ParquetWriteMode(String code, String descriptionKey) {
+    this.code = code;
+    this.descriptionKey = descriptionKey;
+  }
+
+  public static String[] getDescriptions() {
+    String[] descriptions = new String[values().length];
+    for (int i = 0; i < descriptions.length; i++) {
+      descriptions[i] = values()[i].getDescription();
+    }
+    return descriptions;
+  }
+
+  public static ParquetWriteMode getModeFromDescription(String description) {
+    for (ParquetWriteMode mode : values()) {
+      if (mode.getDescription().equals(description)) {
+        return mode;
+      }
+    }
+    return Append;
+  }
+
+  @Override
+  public String getCode() {
+    return code;
+  }
+
+  public String getDescription() {
+    return BaseMessages.getString(PKG, descriptionKey);
+  }
+}
diff --git 
a/plugins/tech/parquet/src/main/resources/org/apache/hop/parquet/transforms/output/messages/messages_en_US.properties
 
b/plugins/tech/parquet/src/main/resources/org/apache/hop/parquet/transforms/output/messages/messages_en_US.properties
index db680bb9cb..2f568cf488 100644
--- 
a/plugins/tech/parquet/src/main/resources/org/apache/hop/parquet/transforms/output/messages/messages_en_US.properties
+++ 
b/plugins/tech/parquet/src/main/resources/org/apache/hop/parquet/transforms/output/messages/messages_en_US.properties
@@ -17,6 +17,10 @@
 
 ParquetOutput.Description=Writes rows of data to a Parquet file
 ParquetOutput.Name=Parquet file output
+ParquetOutput.WriteMode.Append=Append
+ParquetOutput.WriteMode.FailIfExists=Fail if exists
+ParquetOutput.WriteMode.OverwriteAll=Overwrite all
+ParquetOutput.WriteMode.OverwritePartitions=Overwrite partitions
 ParquetOutputDialog.CompressionCodec.Label=Compression codec
 ParquetOutputDialog.DataPageSize.Label=Data page size
 ParquetOutputDialog.DictionaryPageSize.Label=Dictionary page size
@@ -24,8 +28,8 @@ ParquetOutputDialog.Fields.Label=Fields (leave empty to 
output all input fields)
 ParquetOutputDialog.FieldsColumn.SourceField.Label=Source field
 ParquetOutputDialog.FieldsColumn.TargetField.Label=Target field
 ParquetOutputDialog.FilenameBase.Label=Base file name
-ParquetOutputDialog.FilenameCreateFolders.Label=Create parent folders
 ParquetOutputDialog.FilenameCompressionBeforeExtension.Label=Include 
compression codec before extension
+ParquetOutputDialog.FilenameCreateFolders.Label=Create parent folders
 ParquetOutputDialog.FilenameDateTimeFormat.Label=Date time format
 ParquetOutputDialog.FilenameExtension.Label=Extension
 ParquetOutputDialog.FilenameGroup.Label=Filename
@@ -35,8 +39,15 @@ ParquetOutputDialog.FilenameIncludeDateTime.Label=Include 
date-time format
 ParquetOutputDialog.FilenameIncludeSplitNr.Label=Split into parts and include 
number
 ParquetOutputDialog.FilenameIncludeTime.Label=Include time
 ParquetOutputDialog.FilenameSplitSize.Label=Split size
-ParquetOutputDialog.RowGroupSize.Label Row group size
+ParquetOutputDialog.MaxOpenPartitions.Label=Maximum open partitions
+ParquetOutputDialog.MaxOpenPartitions.ToolTip=How many partition files may be 
written at the same time. Each open file buffers up to one row group in memory, 
so a lower number uses less memory but writes more files.
+ParquetOutputDialog.PartitionFields.Label=Partition by (leave empty to write a 
single file set)
+ParquetOutputDialog.PartitionFieldsColumn.Field.Label=Field
+ParquetOutputDialog.PartitionGroup.Label=Partitioning
+ParquetOutputDialog.RowGroupSize.Label=Row group size
 ParquetOutputDialog.TransformName.Label=Transform name
 ParquetOutputDialog.Version.Label=Version
-ParquetOutputMeta.keyword=parquet,columnar,write,file,apache
+ParquetOutputDialog.WriteMode.Label=Existing data
+ParquetOutputDialog.WriteMode.ToolTip=What to do with data already present in 
a partition folder.
 ParquetOutputDialog.extension.Label=Parquet files
+ParquetOutputMeta.keyword=parquet,columnar,write,file,apache
diff --git 
a/plugins/tech/parquet/src/test/java/org/apache/hop/parquet/transforms/output/ParquetOutputPartitionTest.java
 
b/plugins/tech/parquet/src/test/java/org/apache/hop/parquet/transforms/output/ParquetOutputPartitionTest.java
new file mode 100644
index 0000000000..efcca79455
--- /dev/null
+++ 
b/plugins/tech/parquet/src/test/java/org/apache/hop/parquet/transforms/output/ParquetOutputPartitionTest.java
@@ -0,0 +1,419 @@
+/*
+ * 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.hop.parquet.transforms.output;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Stream;
+import org.apache.hop.core.RowMetaAndData;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.Pipeline;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.engines.local.LocalPipelineEngine;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import org.apache.parquet.hadoop.metadata.CompressionCodecName;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Tests for the Hive-style partitioned output of {@link ParquetOutput}. */
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class ParquetOutputPartitionTest {
+
+  @TempDir private Path tempDir;
+
+  private TransformMockHelper<ParquetOutputMeta, ParquetOutputData> mockHelper;
+
+  @BeforeEach
+  void setUp() {
+    mockHelper =
+        new TransformMockHelper<>(
+            "Parquet Output", ParquetOutputMeta.class, 
ParquetOutputData.class);
+    when(mockHelper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+        .thenReturn(mockHelper.iLogChannel);
+    when(mockHelper.pipeline.isRunning()).thenReturn(true);
+  }
+
+  @AfterEach
+  void tearDown() {
+    mockHelper.cleanUp();
+  }
+
+  @Test
+  void partitionPathUsesHiveStyleNameEqualsValue() throws Exception {
+    ParquetOutputMeta meta = partitionedMeta("region", "year");
+    ParquetOutput output = createTransform(meta, new ParquetOutputData());
+    output.setInputRowMeta(salesRowMeta());
+    output.resolveOutputFields();
+
+    assertEquals(
+        "region=EU/year=2026",
+        output.partitionPath(salesRowMeta(), new Object[] {1L, "EU", "2026"}));
+  }
+
+  @Test
+  void partitionPathUsesTheHiveDefaultNameForNullAndEmptyValues() throws 
Exception {
+    ParquetOutputMeta meta = partitionedMeta("region");
+    ParquetOutput output = createTransform(meta, new ParquetOutputData());
+    output.setInputRowMeta(salesRowMeta());
+    output.resolveOutputFields();
+
+    assertEquals(
+        "region=" + ParquetOutput.DEFAULT_PARTITION_NAME,
+        output.partitionPath(salesRowMeta(), new Object[] {1L, null, "2026"}));
+    assertEquals(
+        "region=" + ParquetOutput.DEFAULT_PARTITION_NAME,
+        output.partitionPath(salesRowMeta(), new Object[] {1L, "", "2026"}));
+  }
+
+  @Test
+  void pathHostileCharactersAreEscaped() {
+    // A value containing a separator must not create extra folder levels.
+    assertEquals("a%2Fb", ParquetOutput.escapePathValue("a/b"));
+    assertEquals("a%5Cb", ParquetOutput.escapePathValue("a\\b"));
+    assertEquals("a%3Db", ParquetOutput.escapePathValue("a=b"));
+    assertEquals("a%3Ab", ParquetOutput.escapePathValue("a:b"));
+    assertEquals("a%09b", ParquetOutput.escapePathValue("a\tb"));
+    assertEquals("100%25", ParquetOutput.escapePathValue("100%"));
+    assertEquals("plain-value_1", 
ParquetOutput.escapePathValue("plain-value_1"));
+    // VFS percent-decodes the path it is handed, so our own escapes are 
doubled on the way out
+    assertEquals("region=EU%252FWest", 
ParquetOutput.toVfsPath("region=EU%2FWest"));
+  }
+
+  @Test
+  void partitionFolderStripsTrailingSeparators() {
+    ParquetOutputMeta meta = new ParquetOutputMeta();
+    ParquetOutput output = createTransform(meta, new ParquetOutputData());
+
+    meta.setFilenameBase("/tmp/sales/");
+    assertEquals("/tmp/sales/region=EU", output.partitionFolder("region=EU"));
+
+    meta.setFilenameBase("/tmp/sales\\");
+    assertEquals("/tmp/sales/region=EU", output.partitionFolder("region=EU"));
+
+    meta.setFilenameBase("/tmp/sales");
+    assertEquals("/tmp/sales/region=EU", output.partitionFolder("region=EU"));
+  }
+
+  @Test
+  void partitionFieldsAreNotWrittenIntoTheFile() throws Exception {
+    ParquetOutputMeta meta = partitionedMeta("region");
+    ParquetOutputData data = new ParquetOutputData();
+    ParquetOutput output = createTransform(meta, data);
+    output.setInputRowMeta(salesRowMeta());
+
+    output.resolveOutputFields();
+
+    assertEquals(2, data.outputFields.size());
+    assertEquals("id", data.outputFields.get(0).getSourceFieldName());
+    assertEquals("year", data.outputFields.get(1).getSourceFieldName());
+    assertEquals(List.of(1), data.partitionFieldIndexes);
+  }
+
+  @Test
+  void partitioningOnEveryFieldIsRejected() throws Exception {
+    ParquetOutputMeta meta = partitionedMeta("id", "region", "year");
+    ParquetOutput output = createTransform(meta, new ParquetOutputData());
+    output.setInputRowMeta(salesRowMeta());
+
+    HopException e = assertThrows(HopException.class, 
output::resolveOutputFields);
+    assertTrue(e.getMessage().contains("nothing to write"));
+  }
+
+  @Test
+  void unknownPartitionFieldIsRejected() throws Exception {
+    ParquetOutputMeta meta = partitionedMeta("nope");
+    ParquetOutput output = createTransform(meta, new ParquetOutputData());
+    output.setInputRowMeta(salesRowMeta());
+
+    HopException e = assertThrows(HopException.class, 
output::resolveOutputFields);
+    assertTrue(e.getMessage().contains("nope"));
+  }
+
+  @Test
+  void duplicatePartitionFieldIsRejected() throws Exception {
+    ParquetOutputMeta meta = partitionedMeta("region", "region");
+    ParquetOutput output = createTransform(meta, new ParquetOutputData());
+    output.setInputRowMeta(salesRowMeta());
+
+    HopException e = assertThrows(HopException.class, 
output::resolveOutputFields);
+    assertTrue(e.getMessage().contains("more than once"));
+  }
+
+  @Test
+  void writesOneFolderPerPartitionAndOmitsThePartitionColumn() throws 
Exception {
+    ParquetOutputMeta meta = partitionedMeta("region");
+    meta.setFilenameBase(tempDir.resolve("sales").toString());
+
+    runRows(
+        meta,
+        new Object[] {1L, "EU", "2026"},
+        new Object[] {2L, "US", "2026"},
+        new Object[] {3L, "EU", "2026"});
+
+    // Hive-style layout, one folder per distinct value.
+    assertEquals(List.of("region=EU", "region=US"), 
names(childFolders(tempDir.resolve("sales"))));
+
+    // The written file no longer carries the partition column ...
+    Path euFile = onlyFile(tempDir.resolve("sales/region=EU"));
+    IRowMeta schema = ParquetTestUtil.readSchema(euFile.toString());
+    assertEquals(2, schema.size());
+    assertEquals("id", schema.getValueMeta(0).getName());
+    assertEquals("year", schema.getValueMeta(1).getName());
+    assertEquals(-1, schema.indexOfValue("region"));
+
+    // ... and both EU rows are in that one file.
+    List<RowMetaAndData> rows = readRows(euFile, "id", "year");
+    assertEquals(2, rows.size());
+    assertEquals(1L, rows.get(0).getInteger("id", -1L));
+    assertEquals(3L, rows.get(1).getInteger("id", -1L));
+  }
+
+  @Test
+  void aPartitionValueWithASeparatorStaysOneFolderLevel() throws Exception {
+    ParquetOutputMeta meta = partitionedMeta("region");
+    meta.setFilenameBase(tempDir.resolve("sales").toString());
+
+    runRows(meta, new Object[] {1L, "EU/West", "2026"});
+
+    assertEquals(List.of("region=EU%2FWest"), 
names(childFolders(tempDir.resolve("sales"))));
+  }
+
+  @Test
+  void appendModeLeavesEarlierFilesInPlace() throws Exception {
+    ParquetOutputMeta meta = partitionedMeta("region");
+    meta.setFilenameBase(tempDir.resolve("sales").toString());
+    meta.setWriteMode(ParquetWriteMode.Append);
+
+    runRows(meta, new Object[] {1L, "EU", "2026"});
+    runRows(meta, new Object[] {2L, "EU", "2026"});
+
+    assertEquals(2, listFiles(tempDir.resolve("sales/region=EU")).size());
+  }
+
+  @Test
+  void overwritePartitionsModeEmptiesOnlyTheTouchedPartition() throws 
Exception {
+    ParquetOutputMeta meta = partitionedMeta("region");
+    meta.setFilenameBase(tempDir.resolve("sales").toString());
+
+    meta.setWriteMode(ParquetWriteMode.Append);
+    runRows(meta, new Object[] {1L, "EU", "2026"}, new Object[] {2L, "US", 
"2026"});
+    assertEquals(1, listFiles(tempDir.resolve("sales/region=EU")).size());
+    assertEquals(1, listFiles(tempDir.resolve("sales/region=US")).size());
+
+    meta.setWriteMode(ParquetWriteMode.OverwritePartitions);
+    runRows(meta, new Object[] {3L, "EU", "2026"});
+
+    // EU was rewritten, US was not touched.
+    assertEquals(1, listFiles(tempDir.resolve("sales/region=EU")).size());
+    assertEquals(1, listFiles(tempDir.resolve("sales/region=US")).size());
+    List<RowMetaAndData> euRows = 
readRows(onlyFile(tempDir.resolve("sales/region=EU")), "id");
+    assertEquals(1, euRows.size());
+    assertEquals(3L, euRows.get(0).getInteger("id", -1L));
+  }
+
+  @Test
+  void overwritePartitionsDoesNotDeleteWhatTheSameRunJustWrote() throws 
Exception {
+    ParquetOutputMeta meta = partitionedMeta("region");
+    meta.setFilenameBase(tempDir.resolve("sales").toString());
+    meta.setWriteMode(ParquetWriteMode.OverwritePartitions);
+    // One open partition at a time forces EU to be closed and reopened 
between the two EU rows.
+    meta.setMaxOpenPartitions("1");
+
+    runRows(
+        meta,
+        new Object[] {1L, "EU", "2026"},
+        new Object[] {2L, "US", "2026"},
+        new Object[] {3L, "EU", "2026"});
+
+    // Two files for EU because it was reopened, and the first was not wiped 
by the second.
+    assertEquals(2, listFiles(tempDir.resolve("sales/region=EU")).size());
+  }
+
+  @Test
+  void failIfExistsModeRefusesAnExistingPartition() throws Exception {
+    ParquetOutputMeta meta = partitionedMeta("region");
+    meta.setFilenameBase(tempDir.resolve("sales").toString());
+
+    meta.setWriteMode(ParquetWriteMode.Append);
+    runRows(meta, new Object[] {1L, "EU", "2026"});
+
+    meta.setWriteMode(ParquetWriteMode.FailIfExists);
+    HopException e =
+        assertThrows(HopException.class, () -> runRows(meta, new Object[] {2L, 
"EU", "2026"}));
+    assertTrue(e.getMessage().contains("already exists"));
+  }
+
+  @Test
+  void overwriteAllModeEmptiesEveryPartition() throws Exception {
+    ParquetOutputMeta meta = partitionedMeta("region");
+    meta.setFilenameBase(tempDir.resolve("sales").toString());
+
+    meta.setWriteMode(ParquetWriteMode.Append);
+    runRows(meta, new Object[] {1L, "EU", "2026"}, new Object[] {2L, "US", 
"2026"});
+
+    meta.setWriteMode(ParquetWriteMode.OverwriteAll);
+    runRows(meta, new Object[] {3L, "APAC", "2026"});
+
+    assertEquals(List.of("region=APAC"), 
names(childFolders(tempDir.resolve("sales"))));
+  }
+
+  @Test
+  void moreDistinctValuesThanOpenFilesStillWritesEveryRow() throws Exception {
+    ParquetOutputMeta meta = partitionedMeta("region");
+    meta.setFilenameBase(tempDir.resolve("sales").toString());
+    meta.setMaxOpenPartitions("2");
+
+    List<Object[]> rows = new ArrayList<>();
+    for (int i = 0; i < 6; i++) {
+      rows.add(new Object[] {(long) i, "r" + i, "2026"});
+    }
+    runRows(meta, rows.toArray(new Object[0][]));
+
+    assertEquals(6, childFolders(tempDir.resolve("sales")).size());
+    for (int i = 0; i < 6; i++) {
+      Path folder = tempDir.resolve("sales/region=r" + i);
+      assertEquals(1, listFiles(folder).size(), "expected one file in " + 
folder);
+    }
+  }
+
+  @Test
+  void withoutPartitionFieldsNothingChanges() throws Exception {
+    ParquetOutputMeta meta = new ParquetOutputMeta();
+    meta.setFilenameBase(tempDir.resolve("flat").toString());
+    meta.setCompressionCodec(CompressionCodecName.UNCOMPRESSED);
+    meta.setFilenameIncludingCopyNr(false);
+    meta.setFilenameIncludingSplitNr(false);
+    meta.setRowGroupSize("4096");
+    meta.setDataPageSize("1024");
+    meta.setDictionaryPageSize("512");
+
+    assertFalse(meta.isPartitioning());
+    runRows(meta, new Object[] {1L, "EU", "2026"});
+
+    // A single file at the base name, no folders, and the region column is 
still written.
+    Path file = tempDir.resolve("flat.parquet");
+    assertTrue(Files.exists(file), "expected " + file);
+    assertEquals(3, ParquetTestUtil.readSchema(file.toString()).size());
+  }
+
+  // 
------------------------------------------------------------------------------------------
+
+  private ParquetOutputMeta partitionedMeta(String... partitionFields) {
+    ParquetOutputMeta meta = new ParquetOutputMeta();
+    meta.setCompressionCodec(CompressionCodecName.UNCOMPRESSED);
+    meta.setFilenameIncludingSplitNr(false);
+    // The default row group size is 256MB and every open writer buffers up to 
one row group, so
+    // the tests that open several partitions at once would otherwise reserve 
gigabytes.
+    meta.setRowGroupSize("4096");
+    meta.setDataPageSize("1024");
+    meta.setDictionaryPageSize("512");
+    for (String field : partitionFields) {
+      meta.getPartitionFields().add(new ParquetPartitionField(field));
+    }
+    return meta;
+  }
+
+  private static IRowMeta salesRowMeta() {
+    RowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaInteger("id"));
+    rowMeta.addValueMeta(new ValueMetaString("region"));
+    rowMeta.addValueMeta(new ValueMetaString("year"));
+    return rowMeta;
+  }
+
+  /** Feeds the given rows through a fresh transform instance and closes its 
files. */
+  private void runRows(ParquetOutputMeta meta, Object[]... rows) throws 
Exception {
+    ParquetOutputData data = new ParquetOutputData();
+    ParquetOutput output = spy(createTransform(meta, data));
+    output.setInputRowMeta(salesRowMeta());
+    assertTrue(output.init());
+
+    List<Object[]> remaining = new ArrayList<>(List.of(rows));
+    doNothing().when(output).putRow(any(), any());
+    // doAnswer(...).when(spy) rather than when(spy.getRow()): the latter 
invokes the real getRow()
+    // while stubbing, which blocks forever in waitUntilPipelineIsStarted().
+    doAnswer(invocation -> remaining.isEmpty() ? null : 
remaining.remove(0)).when(output).getRow();
+
+    while (output.processRow()) {
+      // keep going until the null row closes the files
+    }
+  }
+
+  private ParquetOutput createTransform(ParquetOutputMeta meta, 
ParquetOutputData data) {
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    TransformMeta transformMeta = new TransformMeta("Parquet Output", meta);
+    pipelineMeta.addTransform(transformMeta);
+    Pipeline pipeline = new LocalPipelineEngine(pipelineMeta);
+    return new ParquetOutput(transformMeta, meta, data, 0, pipelineMeta, 
pipeline);
+  }
+
+  private static List<RowMetaAndData> readRows(Path file, String... 
fieldNames) throws Exception {
+    List<org.apache.hop.parquet.transforms.input.ParquetField> fields = new 
ArrayList<>();
+    for (String name : fieldNames) {
+      fields.add(
+          new org.apache.hop.parquet.transforms.input.ParquetField(
+              name, name, "id".equals(name) ? "Integer" : "String", null, "0", 
"0"));
+    }
+    return ParquetTestUtil.readAllRows(file.toString(), fields);
+  }
+
+  private static List<Path> childFolders(Path parent) throws IOException {
+    try (Stream<Path> stream = Files.list(parent)) {
+      return 
stream.filter(Files::isDirectory).sorted(Comparator.naturalOrder()).toList();
+    }
+  }
+
+  private static List<Path> listFiles(Path folder) throws IOException {
+    try (Stream<Path> stream = Files.list(folder)) {
+      return 
stream.filter(Files::isRegularFile).sorted(Comparator.naturalOrder()).toList();
+    }
+  }
+
+  private static Path onlyFile(Path folder) throws IOException {
+    List<Path> files = listFiles(folder);
+    assertEquals(1, files.size(), "expected exactly one file in " + folder);
+    return files.get(0);
+  }
+
+  private static List<String> names(List<Path> paths) {
+    return paths.stream().map(p -> p.getFileName().toString()).toList();
+  }
+}

Reply via email to