You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This report was investigated and written with AI assistance (Claude Code), posted with the account owner's review and consent.
Describe the bug
DataFusion 54 prunes a Parquet row group from statistics alone when the predicate is col = <literal> and the row group's statistics record null_count == row_count for that column (equality with a non-NULL literal cannot match any row). DataFusion 55.0.0 builds the same pruning predicate — including the <col>_null_count@N != row_count@M clause — but no longer prunes the row group; it is scanned instead.
Query results are unaffected (0 rows on both versions). The regression is scan work: for workloads where a selective equality column is sparsely populated (in our case, a body column that is NULL for the overwhelming majority of rows), row groups that 54 skipped from the footer are now read.
To Reproduce
Self-contained reproducer (~70 lines, inlined below): writes a single-row-group Parquet file with one nullable Binary column, all 100 values NULL, default WriterProperties and default SessionContext, registers it as a ListingTable, and filters with the DataFrame API:
[package]
name = "df-pruning-repro"version = "0.0.0"edition = "2021"
[dependencies]
# Flip to "54" and the row group is pruned; on "55" it is scanned.datafusion = "55"tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tempfile = "3"
//! DataFusion 54 prunes a row group whose statistics say a column is//! entirely NULL when the predicate is `col = <literal>`; DataFusion 55//! scans it. Default writer properties, default SessionContext.use std::sync::Arc;use datafusion::arrow::array::{BinaryArray,RecordBatch};use datafusion::arrow::datatypes::{DataType,Field,Schema};use datafusion::common::ScalarValue;use datafusion::datasource::file_format::parquet::ParquetFormat;use datafusion::datasource::listing::{ListingOptions,ListingTable,ListingTableConfig,ListingTableUrl,};use datafusion::parquet::arrow::ArrowWriter;use datafusion::prelude::*;#[tokio::main]asyncfnmain() -> datafusion::error::Result<()>{// One row group, one nullable Binary column, every value NULL —// the footer statistics record null_count == num_rows, no min/max.let schema = Arc::new(Schema::new(vec![Field::new("body",DataType::Binary,true,)]));let batch = RecordBatch::try_new(
schema.clone(),vec![Arc::new(BinaryArray::from(vec![None::<&[u8]>;100]))],).unwrap();let dir = tempfile::tempdir().unwrap();let path = dir.path().join("all_null.parquet");let file = std::fs::File::create(&path).unwrap();letmut w = ArrowWriter::try_new(file, schema,None).unwrap();
w.write(&batch).unwrap();
w.close().unwrap();let ctx = SessionContext::new();let url = ListingTableUrl::parse(format!("file://{}", path.display())).unwrap();let options =
ListingOptions::new(Arc::new(ParquetFormat::default())).with_file_extension(".parquet");let schema = options.infer_schema(&ctx.state(),&url).await?;let table = ListingTable::try_new(ListingTableConfig::new(url).with_listing_options(options).with_schema(schema),)?;// `body = X` can match nothing when every value is NULL, so the row// group is prunable from statistics alone.let df = ctx
.read_table(Arc::new(table))?
.filter(col("body").eq(lit(ScalarValue::Binary(Some(b"x".to_vec())))))?;let plan = df.create_physical_plan().await?;let batches = datafusion::physical_plan::collect(plan.clone(), ctx.task_ctx()).await?;println!("rows = {} (correct on both versions)",
batches.iter().map(|b| b.num_rows()).sum::<usize>());fnwalk(p:&Arc<dyn datafusion::physical_plan::ExecutionPlan>){ifletSome(m) = p.metrics(){for metric in m.iter(){if metric.value().name() == "row_groups_pruned_statistics"{println!("{:?}", metric.value());}}}for c in p.children(){walk(c);}}walk(&plan);Ok(())}
Expected behavior
The row group is pruned on 55 as it was on 54: the file's statistics prove body = 'x' cannot match (null_count == row_count), and the physical plan's pruning_predicate (identical on both versions in our larger application, including the body_null_count != row_count conjunct) already expresses that proof.
Additional context
Found upgrading a Parquet log store (ourios) from DF 54.0.0 → 55.0.0: three pruning-assertion tests went red with pruned: 0 where 54 gave pruned: 2; written files byte-identical across the upgrade (parquet 58 vs 59 emit the same statistics for these columns), so this is read-path only.
Triage hint: the regression reproduces through ListingTable + the DataFrame API filter. In our first attempt we could NOT reproduce via register_parquet + a SQL string (WHERE body = X'6E6F7065') — that path does not prune on either 54 or 55 — so the SQL literal/rewrite path seems to sit on a different guarantee/pruning route and may mask the regression during triage.
EnabledStatistics::Page vs Chunk and dictionary on/off for the column make no difference; defaults reproduce.
Note
This report was investigated and written with AI assistance (Claude Code), posted with the account owner's review and consent.
Describe the bug
DataFusion 54 prunes a Parquet row group from statistics alone when the predicate is
col = <literal>and the row group's statistics recordnull_count == row_countfor that column (equality with a non-NULL literal cannot match any row). DataFusion 55.0.0 builds the same pruning predicate — including the<col>_null_count@N != row_count@Mclause — but no longer prunes the row group; it is scanned instead.Query results are unaffected (0 rows on both versions). The regression is scan work: for workloads where a selective equality column is sparsely populated (in our case, a
bodycolumn that is NULL for the overwhelming majority of rows), row groups that 54 skipped from the footer are now read.To Reproduce
Self-contained reproducer (~70 lines, inlined below): writes a single-row-group Parquet file with one nullable
Binarycolumn, all 100 values NULL, defaultWriterPropertiesand defaultSessionContext, registers it as aListingTable, and filters with the DataFrame API:Output,
datafusion = "54":Output,
datafusion = "55"(only the dependency line changed):Cargo.toml + src/main.rs (complete)
Expected behavior
The row group is pruned on 55 as it was on 54: the file's statistics prove
body = 'x'cannot match (null_count == row_count), and the physical plan'spruning_predicate(identical on both versions in our larger application, including thebody_null_count != row_countconjunct) already expresses that proof.Additional context
pruned: 0where 54 gavepruned: 2; written files byte-identical across the upgrade (parquet 58 vs 59 emit the same statistics for these columns), so this is read-path only.ListingTable+ the DataFrame API filter. In our first attempt we could NOT reproduce viaregister_parquet+ a SQL string (WHERE body = X'6E6F7065') — that path does not prune on either 54 or 55 — so the SQL literal/rewrite path seems to sit on a different guarantee/pruning route and may mask the regression during triage.EnabledStatistics::PagevsChunkand dictionary on/off for the column make no difference; defaults reproduce.