Skip to content
Merged
Prev Previous commit
Next Next commit
feat: add write_row_group_number_distinct_values parquet config option
Add a new ParquetOptions field that enables writing NDV (number of distinct
values) statistics per column when writing parquet files, backed by the
new arrow-rs WriterPropertiesBuilder::set_write_row_group_number_distinct_values
API. Includes proto serialization and a SQL logic roundtrip test.
  • Loading branch information
Rich-T-kid committed Oct 2, 2026
commit 3ad4cd80e8d9bfda0de712ff3e6f8c7d08676dc4
6 changes: 6 additions & 0 deletions datafusion/common/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1514,6 +1514,12 @@ config_namespace! {
/// default parquet writer setting
pub bloom_filter_ndv: Option<u64>, default = None

/// (writing) Write the number of distinct values (NDV) for each column
Comment thread
Rich-T-kid marked this conversation as resolved.
Resolved
/// in row group statistics when creating parquet files. Enabling this
/// improves NDV-based query optimizations at the cost of hashing every
/// non-null value during write.
pub write_row_group_number_distinct_values: bool, default = false

/// (writing) Controls whether DataFusion will attempt to speed up writing
/// parquet files by serializing them in parallel. Each column
/// in each row group in each output file are serialized in parallel
Expand Down
10 changes: 9 additions & 1 deletion datafusion/common/src/file_options/parquet_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ impl ParquetOptions {
bloom_filter_on_write,
bloom_filter_fpp,
bloom_filter_ndv,
write_row_group_number_distinct_values,
content_defined_chunking,

// not in WriterProperties
Expand Down Expand Up @@ -267,7 +268,10 @@ impl ParquetOptions {
.set_column_index_truncate_length(*column_index_truncate_length)
.set_statistics_truncate_length(*statistics_truncate_length)
.set_data_page_row_count_limit(*data_page_row_count_limit)
.set_bloom_filter_enabled(*bloom_filter_on_write);
.set_bloom_filter_enabled(*bloom_filter_on_write)
.set_write_row_group_number_distinct_values(
*write_row_group_number_distinct_values,
);

if let Some(bloom_filter_fpp) = bloom_filter_fpp {
builder = builder.set_bloom_filter_fpp(*bloom_filter_fpp);
Expand Down Expand Up @@ -404,6 +408,8 @@ mod tests {
bloom_filter_on_write: !defaults.bloom_filter_on_write,
bloom_filter_fpp: Some(0.42),
bloom_filter_ndv: Some(42),
write_row_group_number_distinct_values: !defaults
.write_row_group_number_distinct_values,

// not in WriterProperties, but itemizing here to not skip newly added props
enable_page_index: defaults.enable_page_index,
Expand Down Expand Up @@ -551,6 +557,8 @@ mod tests {
schema_force_view_types: global_options_defaults.schema_force_view_types,
binary_as_string: global_options_defaults.binary_as_string,
skip_arrow_metadata: global_options_defaults.skip_arrow_metadata,
write_row_group_number_distinct_values: global_options_defaults
.write_row_group_number_distinct_values,
coerce_int96: None,
coerce_int96_tz: None,
content_defined_chunking: props.content_defined_chunking().into(),
Expand Down
3 changes: 3 additions & 0 deletions datafusion/datasource-parquet/src/file_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,9 @@ impl From<&ParquetFormatFactory> for protobuf::TableParquetOptions {
schema_force_view_types: global_options.global.schema_force_view_types,
binary_as_string: global_options.global.binary_as_string,
skip_arrow_metadata: global_options.global.skip_arrow_metadata,
write_row_group_number_distinct_values: global_options
.global
.write_row_group_number_distinct_values,
coerce_int96_opt: global_options.global.coerce_int96.map(|compression| {
parquet_options::CoerceInt96Opt::CoerceInt96(compression)
}),
Expand Down
1 change: 1 addition & 0 deletions datafusion/proto-common/proto/datafusion_common.proto
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,7 @@ message ParquetOptions {
bool schema_force_view_types = 28; // default = false
bool binary_as_string = 29; // default = false
bool skip_arrow_metadata = 30; // default = false
bool write_row_group_number_distinct_values = 39; // default = false

oneof metadata_size_hint_opt {
uint64 metadata_size_hint = 4;
Expand Down
1 change: 1 addition & 0 deletions datafusion/proto-common/src/from_proto/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,7 @@ impl TryFrom<&protobuf::ParquetOptions> for ParquetOptions {
protobuf::parquet_options::CoerceInt96TzOpt::CoerceInt96Tz(v) => Some(v),
}).unwrap_or(None),
skip_arrow_metadata: value.skip_arrow_metadata,
write_row_group_number_distinct_values: value.write_row_group_number_distinct_values,
max_predicate_cache_size: value.max_predicate_cache_size_opt.map(|opt| match opt {
protobuf::parquet_options::MaxPredicateCacheSizeOpt::MaxPredicateCacheSize(v) => {
to_usize(v, "max_predicate_cache_size")
Expand Down
24 changes: 21 additions & 3 deletions datafusion/proto-common/src/generated/pbjson.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2600,7 +2600,7 @@ impl<'de> serde::Deserialize<'de> for CsvWriterOptions {
if compression_level__.is_some() {
return Err(serde::de::Error::duplicate_field("compressionLevel"));
}
compression_level__ =
compression_level__ =
Comment thread
Rich-T-kid marked this conversation as resolved.
Resolved
map_.next_value::<::std::option::Option<::pbjson::private::NumberDeserialize<_>>>()?.map(|x| x.0)
;
}
Expand All @@ -2614,7 +2614,7 @@ impl<'de> serde::Deserialize<'de> for CsvWriterOptions {
if terminator__.is_some() {
return Err(serde::de::Error::duplicate_field("terminator"));
}
terminator__ =
terminator__ =
Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0)
;
}
Expand Down Expand Up @@ -4056,7 +4056,7 @@ impl serde::Serialize for ExplainAnalyzeCategoriesNode {
struct_ser.serialize_field("all", &self.all)?;
}
if !self.only.is_empty() {
let v = self.only.iter().copied().map(|v| {
let v = self.only.iter().cloned().map(|v| {
MetricCategory::try_from(v)
.map_err(|_| serde::ser::Error::custom(format!("Invalid variant {}", v)))
}).collect::<std::result::Result<Vec<_>, _>>()?;
Expand Down Expand Up @@ -6479,6 +6479,9 @@ impl serde::Serialize for ParquetOptions {
if self.skip_arrow_metadata {
len += 1;
}
if self.write_row_group_number_distinct_values {
len += 1;
}
if self.dictionary_page_size_limit != 0 {
len += 1;
}
Expand Down Expand Up @@ -6596,6 +6599,9 @@ impl serde::Serialize for ParquetOptions {
if self.skip_arrow_metadata {
struct_ser.serialize_field("skipArrowMetadata", &self.skip_arrow_metadata)?;
}
if self.write_row_group_number_distinct_values {
struct_ser.serialize_field("writeRowGroupNumberDistinctValues", &self.write_row_group_number_distinct_values)?;
}
if self.dictionary_page_size_limit != 0 {
#[allow(clippy::needless_borrow)]
#[allow(clippy::needless_borrows_for_generic_args)]
Expand Down Expand Up @@ -6768,6 +6774,8 @@ impl<'de> serde::Deserialize<'de> for ParquetOptions {
"binaryAsString",
"skip_arrow_metadata",
"skipArrowMetadata",
"write_row_group_number_distinct_values",
"writeRowGroupNumberDistinctValues",
"dictionary_page_size_limit",
"dictionaryPageSizeLimit",
"data_page_row_count_limit",
Expand Down Expand Up @@ -6825,6 +6833,7 @@ impl<'de> serde::Deserialize<'de> for ParquetOptions {
SchemaForceViewTypes,
BinaryAsString,
SkipArrowMetadata,
WriteRowGroupNumberDistinctValues,
DictionaryPageSizeLimit,
DataPageRowCountLimit,
MaxRowGroupSize,
Expand Down Expand Up @@ -6882,6 +6891,7 @@ impl<'de> serde::Deserialize<'de> for ParquetOptions {
"schemaForceViewTypes" | "schema_force_view_types" => Ok(GeneratedField::SchemaForceViewTypes),
"binaryAsString" | "binary_as_string" => Ok(GeneratedField::BinaryAsString),
"skipArrowMetadata" | "skip_arrow_metadata" => Ok(GeneratedField::SkipArrowMetadata),
"writeRowGroupNumberDistinctValues" | "write_row_group_number_distinct_values" => Ok(GeneratedField::WriteRowGroupNumberDistinctValues),
"dictionaryPageSizeLimit" | "dictionary_page_size_limit" => Ok(GeneratedField::DictionaryPageSizeLimit),
"dataPageRowCountLimit" | "data_page_row_count_limit" => Ok(GeneratedField::DataPageRowCountLimit),
"maxRowGroupSize" | "max_row_group_size" => Ok(GeneratedField::MaxRowGroupSize),
Expand Down Expand Up @@ -6937,6 +6947,7 @@ impl<'de> serde::Deserialize<'de> for ParquetOptions {
let mut schema_force_view_types__ = None;
let mut binary_as_string__ = None;
let mut skip_arrow_metadata__ = None;
let mut write_row_group_number_distinct_values__ = None;
let mut dictionary_page_size_limit__ = None;
let mut data_page_row_count_limit__ = None;
let mut max_row_group_size__ = None;
Expand Down Expand Up @@ -7068,6 +7079,12 @@ impl<'de> serde::Deserialize<'de> for ParquetOptions {
}
skip_arrow_metadata__ = Some(map_.next_value()?);
}
GeneratedField::WriteRowGroupNumberDistinctValues => {
if write_row_group_number_distinct_values__.is_some() {
return Err(serde::de::Error::duplicate_field("writeRowGroupNumberDistinctValues"));
}
write_row_group_number_distinct_values__ = Some(map_.next_value()?);
}
GeneratedField::DictionaryPageSizeLimit => {
if dictionary_page_size_limit__.is_some() {
return Err(serde::de::Error::duplicate_field("dictionaryPageSizeLimit"));
Expand Down Expand Up @@ -7210,6 +7227,7 @@ impl<'de> serde::Deserialize<'de> for ParquetOptions {
schema_force_view_types: schema_force_view_types__.unwrap_or_default(),
binary_as_string: binary_as_string__.unwrap_or_default(),
skip_arrow_metadata: skip_arrow_metadata__.unwrap_or_default(),
write_row_group_number_distinct_values: write_row_group_number_distinct_values__.unwrap_or_default(),
dictionary_page_size_limit: dictionary_page_size_limit__.unwrap_or_default(),
data_page_row_count_limit: data_page_row_count_limit__.unwrap_or_default(),
max_row_group_size: max_row_group_size__.unwrap_or_default(),
Expand Down
3 changes: 3 additions & 0 deletions datafusion/proto-common/src/generated/prost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,9 @@ pub struct ParquetOptions {
/// default = false
#[prost(bool, tag = "30")]
pub skip_arrow_metadata: bool,
/// default = false
#[prost(bool, tag = "39")]
pub write_row_group_number_distinct_values: bool,
#[prost(uint64, tag = "12")]
pub dictionary_page_size_limit: u64,
#[prost(uint64, tag = "18")]
Expand Down
1 change: 1 addition & 0 deletions datafusion/proto-common/src/to_proto/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,7 @@ impl TryFrom<&ParquetOptions> for protobuf::ParquetOptions {
schema_force_view_types: value.schema_force_view_types,
binary_as_string: value.binary_as_string,
skip_arrow_metadata: value.skip_arrow_metadata,
write_row_group_number_distinct_values: value.write_row_group_number_distinct_values,
coerce_int96_opt: value.coerce_int96.clone().map(protobuf::parquet_options::CoerceInt96Opt::CoerceInt96),
coerce_int96_tz_opt: value.coerce_int96_tz.clone().map(protobuf::parquet_options::CoerceInt96TzOpt::CoerceInt96Tz),
max_predicate_cache_size_opt: value.max_predicate_cache_size.map(|v| protobuf::parquet_options::MaxPredicateCacheSizeOpt::MaxPredicateCacheSize(v as u64)),
Expand Down
2 changes: 2 additions & 0 deletions datafusion/proto-models/src/from_proto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,8 @@ impl TryFrom<&ParquetOptionsProto> for ParquetOptions {
schema_force_view_types: proto.schema_force_view_types,
binary_as_string: proto.binary_as_string,
skip_arrow_metadata: proto.skip_arrow_metadata,
write_row_group_number_distinct_values: proto
.write_row_group_number_distinct_values,
coerce_int96: proto.coerce_int96_opt.as_ref().map(|opt| match opt {
parquet_options::CoerceInt96Opt::CoerceInt96(coerce_int96) => {
coerce_int96.clone()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,9 @@ pub struct ParquetOptions {
/// default = false
#[prost(bool, tag = "30")]
pub skip_arrow_metadata: bool,
/// default = false
#[prost(bool, tag = "39")]
pub write_row_group_number_distinct_values: bool,
#[prost(uint64, tag = "12")]
pub dictionary_page_size_limit: u64,
#[prost(uint64, tag = "18")]
Expand Down
97 changes: 97 additions & 0 deletions datafusion/sqllogictest/test_files/parquet_ndv_write.slt
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# 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.

# Roundtrip test for write_row_group_number_distinct_values.
#
# Writes a parquet file with NDV tracking enabled and verifies that
# DataFusion reads back the distinct count from the parquet statistics.

statement ok
set datafusion.explain.physical_plan_only = true;

statement ok
set datafusion.explain.show_statistics = true;

statement ok
set datafusion.execution.collect_statistics = true;

######
# Without the option (default), distinct counts are absent.
######

query I
COPY (VALUES (1), (2), (3), (1))
TO 'test_files/scratch/parquet_ndv_write/no_ndv.parquet'
STORED AS PARQUET;
----
4

statement ok
CREATE EXTERNAL TABLE no_ndv
STORED AS PARQUET
LOCATION 'test_files/scratch/parquet_ndv_write/no_ndv.parquet';

# Distinct count should be absent (no NDV written to the file)
query TT
EXPLAIN SELECT * FROM no_ndv;
----
physical_plan DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/parquet_ndv_write/no_ndv.parquet]]}, projection=[column1], file_type=parquet, statistics=[Rows=Exact(4), Bytes=Exact(32), [(Col[0]: Min=Exact(Int64(1)) Max=Exact(Int64(3)) Null=Exact(0) ScanBytes=Exact(32))]]

statement ok
DROP TABLE no_ndv;

######
# With write_row_group_number_distinct_values = true, distinct count is written
# and read back as Exact (single row group).
######

statement ok
set datafusion.execution.parquet.write_row_group_number_distinct_values = true;

query I
COPY (VALUES (1), (2), (3), (1))
TO 'test_files/scratch/parquet_ndv_write/with_ndv.parquet'
STORED AS PARQUET;
----
4

statement ok
CREATE EXTERNAL TABLE with_ndv
STORED AS PARQUET
LOCATION 'test_files/scratch/parquet_ndv_write/with_ndv.parquet';

# Distinct count should now be Exact(3) — 3 distinct values among 4 rows
query TT
EXPLAIN SELECT * FROM with_ndv;
----
physical_plan DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/parquet_ndv_write/with_ndv.parquet]]}, projection=[column1], file_type=parquet, statistics=[Rows=Exact(4), Bytes=Exact(32), [(Col[0]: Min=Exact(Int64(1)) Max=Exact(Int64(3)) Null=Exact(0) Distinct=Exact(3) ScanBytes=Exact(32))]]

statement ok
DROP TABLE with_ndv;

# Reset settings
statement ok
RESET datafusion.execution.parquet.write_row_group_number_distinct_values;

statement ok
RESET datafusion.explain.show_statistics;

statement ok
RESET datafusion.explain.physical_plan_only;

statement ok
RESET datafusion.execution.collect_statistics;