Skip to content

[C++][Parquet] Wrong statistics for dictionary-encoded string columns: nulls under a null struct are not counted, unused dictionary values end up in min/max #51626

Description

@kita-renji

Describe the bug, including details regarding any error messages, version, and platform.

When a dictionary<*, string|binary> column is written with dictionary encoding (the default), the direct dictionary write path (WriteArrowDictionary in cpp/src/parquet/column_writer.cc) can write wrong statistics in two cases:

  1. The column sits under a struct that has null rows, and the child slots under those rows are valid. That's what pa.array([...], type=struct<x: dictionary<...>>) gives you for None rows. The null rows are not counted in null_count, and a dictionary value that only appears under a null row can become the min or max.
  2. A write batch contains at least one null and references all but one of the dictionary entries. The unreferenced entry can become the min or max. A pandas Categorical with one unused category and a few missing values is enough.

min/max are written with is_min_value_exact / is_max_value_exact set to true, and readers act on these statistics:

import pyarrow as pa, pyarrow.parquet as pq

def stats(path):
    s = pq.ParquetFile(path).metadata.row_group(0).column(0).statistics
    return s.null_count, s.min, s.max

# (1) dictionary leaf under a struct with null rows
d = pa.struct([("x", pa.dictionary(pa.int32(), pa.string()))])
p = pa.struct([("x", pa.string())])
rows = [{"x": "b"}, None, {"x": "c"}, None]
pq.write_table(pa.table({"s": pa.array(rows, type=d)}), "a_dict.parquet")
pq.write_table(pa.table({"s": pa.array(rows, type=p)}), "a_plain.parquet")
print(stats("a_dict.parquet"))   # (0, 'b', 'c')  expected (2, 'b', 'c')
print(stats("a_plain.parquet"))  # (2, 'b', 'c')

x = pa.DictionaryArray.from_arrays(pa.array([0, 1, 0, 1], pa.int32()), pa.array(["b", "zzz"]))
s = pa.StructArray.from_arrays([x], ["x"], mask=pa.array([False, True, False, True]))
pq.write_table(pa.table({"s": s}), "a_hidden.parquet")
print(stats("a_hidden.parquet")) # (0, 'b', 'zzz')  expected (2, 'b', 'b')

# (2) one unreferenced dictionary entry plus a null
c = pa.DictionaryArray.from_arrays(pa.array([0, None, 0], pa.int32()), pa.array(["b", "zzz"]))
pq.write_table(pa.table({"c": c}), "b.parquet")
print(stats("b.parquet"))        # (1, 'b', 'zzz')  expected (1, 'b', 'b')

What other readers make of these files:

Query File Result Expected
DuckDB 1.5.5 SELECT count(*) FROM 'a_dict.parquet' WHERE s.x IS NULL (1) 0 2
DuckDB 1.5.5 SELECT count(s.x) FROM 'a_dict.parquet' (1) 4 2
DataFusion 55.1 SELECT min(c), max(c) FROM 'b.parquet' (2) b, zzz b, b

With the same data written from a plain string child, or with use_dictionary=False, all of these are correct. A 100k-row pandas Categorical with categories ["active", "inactive", "suspended"], where "suspended" never occurs and about 1% of rows are missing, gets max='suspended', and DataFusion returns that for max(status). With data_page_version="2.0", a debug build hits column_writer.cc:1089: Check failed: !page_stats.has_null_count || page_stats.null_count == null_count on case (1).

Seen with pyarrow 25.0.1 and on current main (d0f318d), macOS arm64.

Cause, as far as I can tell (line numbers from d0f318d):

  • (1) WriteIndicesChunk calls update_stats on the raw indices (column_writer.cc:2047) before MaybeReplaceValidity (:2051) replaces their validity with the one derived from the def levels. So an index under a null parent is counted as a value (:2017) and passed to Unique (:2002). The dense path computes statistics after MaybeReplaceValidity.
  • (2) Unique returns one null entry when the batch has null indices, so the "re-use the whole dictionary" check at :2006 passes when exactly one entry is unreferenced.

Once (1) is fixed, the null parents become null indices, so (1) then runs into (2). Both need fixing together.

Related: #51097 / #51357 fixed the same null_count invariant for leaves under a repeated ancestor. Its test has no null struct rows, so it doesn't reach this path.

I'll open a PR with a fix and tests.

Component(s)

C++, Parquet

Activity

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions