Skip to content

Plain SELECT of a Parquet float column returns wrong values: NaN rows are replaced by the statistics constant when min == max #26490

Description

@hanke580

Describe the bug

Parquet writers leave NaN out of floating-point min/max statistics (the spec requires it). So a column chunk holding
one distinct finite value plus some NaNs has statistics min == max == x and null_count == 0, although its values are
not constant. DataFusion treats such a column as constant and substitutes the statistic for the data. A plain
SELECT * FROM 'file.parquet' then returns x for every NaN row. There is no error, and NaNs silently disappear.
pyarrow, DuckDB and Polars read the same file correctly.

This is not limited to pruning: datafusion.execution.parquet.pruning = false and enable_page_index = false do not
change the result. datafusion.execution.collect_statistics = false does. A COPY … TO from such a file writes the
wrong values into the new files.

The shortcut is constant_columns_from_stats / constant_value_from_stats in
datafusion/datasource-parquet/src/opener/mod.rs (lines 1968 and 1999, called at line 891): min == max && !min.is_null() && null_count == Exact(0). For Float16/32/64, Parquet min/max cannot prove the column is constant, because NaN is excluded from them.
So floating-point columns should not be treated as constant from these statistics (unless a NaN count of 0 is known).

Draft PR #24804 (for #15812, pruning with NaN) would disable floating-point min/max in a way that also covers this
("constant-column substitution"), but it is a draft and has no issue for this user-visible symptom. I'm filing it so
the wrong-result case is tracked and tested.

To Reproduce

import pyarrow as pa, pyarrow.parquet as pq
pq.write_table(pa.table({'c0': pa.array([float('nan'), -8.6e-27])}), 'nan.parquet')
pq.write_table(pa.table({'c0': pa.array([1.5, float('nan')], pa.float32())}), 'nan_f32.parquet')
pq.write_table(pa.table({'c0': pa.array([float('nan'), -8.6e-27])}), 'nan_nostats.parquet', write_statistics=False)
$ datafusion-cli -q -c "SELECT * FROM 'nan.parquet';" -c "SELECT count(*) FILTER (WHERE isnan(c0)) AS nans FROM 'nan.parquet';"
+----------+
| c0       |
+----------+
| -8.6e-27 |
| -8.6e-27 |      <-- this row is NaN in the file
+----------+
+------+
| nans |
+------+
| 0    |
+------+

$ datafusion-cli -q -c "SELECT * FROM 'nan_f32.parquet';"
| 1.5 |
| 1.5 |            <-- NaN in the file

$ datafusion-cli -q -c "SELECT * FROM 'nan_nostats.parquet';"
| NaN      |
| -8.6e-27 |       <-- correct without statistics

$ datafusion-cli -q -c "SET datafusion.execution.collect_statistics = false;" -c "SELECT * FROM 'nan.parquet';"
| NaN      |
| -8.6e-27 |       <-- correct

The same happens for files written by DataFusion's own COPY. [NaN, 2.0, 3.0] (min ≠ max) and [NaN, NaN] (no min/max
written) are read correctly.

Both column orders are affected. The pyarrow files above have TYPE_DEFINED_ORDER and no nan_count. DataFusion/arrow-rs
60 writes IEEE_754_TOTAL_ORDER with nan_count, but still leaves NaN out of min/max: COPY (VALUES ('NaN'::double), (5.0))
gives min = max = 5.0, null_count = 0, nan_count = 1, and that file is read back as 5.0, 5.0. So DataFusion ignores a
nan_count that the file does carry.

Expected behavior

SELECT * FROM 'nan.parquet' returns NaN, -8.6e-27, as pyarrow, DuckDB and Polars do. Float statistics with min == max
should not be used to replace the column by a constant, because NaN values are not reflected in them.

Additional context

Willingness to contribute

None

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Prolific Authorauthor opened PRs or issues in more than 20 repositories in the last 30 daysbugSomething isn't working

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions