Skip to content

fix(parquet): honor requested dictionary value type - #12161

Merged
Rich-T-kid merged 7 commits into
apache:mainfrom
Rich-T-kid:parquet-dictionary-requested-value-type
Nov 13, 2026
Merged

Rich-T-kid merged 7 commits into
apache:mainfrom
Rich-T-kid:parquet-dictionary-requested-value-type

Conversation

@Rich-T-kid

@Rich-T-kid Rich-T-kid commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

A Parquet BYTE_ARRAY column without a UTF8 annotation is physically decoded as Binary. When a caller supplies a schema requesting Dictionary(Int32, Utf8), the dictionary reader previously still returned Dictionary(Int32, Binary), requiring the scan consumer to cast every emitted batch.

For a dictionary page containing potentially thousands of unique values:

values = [
    b"mobile", b"desktop", b"tablet", b"bot",
    b"tv", b"console", b"wearable", b"other", ...
]
keys = [0, 0, 1, 2, 0, 3, 1, 4, 5, 0, 6, 7, ...]

Before this PR, batches from the same dictionary page repeatedly converted and allocated the entire dictionary values array:

                    Parquet dictionary page
              Binary dictionary values (potentially thousands)
       [mobile, desktop, tablet, bot, tv, console, wearable, other, ...]
                              |
                    decode batches with shared
                       Binary dictionary values
                              |
          +-------------------+-------------------+
          |                   |                   |
       batch 1             batch 2             batch N
       keys [..]           keys [..]           keys [..]
          |                   |                   |
          | cast ALL          | cast ALL          | cast ALL
          | dictionary        | dictionary        | dictionary
          | values again      | values again      | values again
          v                   v                   v
     allocate Utf8       allocate Utf8       allocate Utf8
     [mobile, ...]       [mobile, ...]       [mobile, ...]
          |                   |                   |
          v                   v                   v
    Dict<Int32, Utf8>   Dict<Int32, Utf8>   Dict<Int32, Utf8>

       same validation, conversion, and allocation repeated per batch

After this PR, the reader converts the unique values once per dictionary page and shares the result with every batch:

                    Parquet dictionary page
              Binary dictionary values (potentially thousands)
       [mobile, desktop, tablet, bot, tv, console, wearable, other, ...]
                              |
                  validate + convert ALL values ONCE
                              |
                              v
                     cached Utf8 values
       [mobile, desktop, tablet, bot, tv, console, wearable, other, ...]
                              |
                   +----------+----------+
                   |          |          |
                batch 1    batch 2    batch N
                keys [..]  keys [..]  keys [..]
                   |          |          |
                   +---- share cached ---+
                         Utf8 values
                   |          |          |
                   v          v          v
             Dict<Int32, Utf8> ... Dict<Int32, Utf8>

        one validation, conversion, and allocation per dictionary page

The potentially millions of keys are preserved; only the unique dictionary values are converted. Invalid UTF-8 still returns an error. Plain-encoded fallback pages retain the existing dictionary-building path.

Are these changes tested?

Coverage includes binary dictionary pages requested as Utf8, LargeUtf8, and Utf8View, reuse across batches, nulls, invalid UTF-8 rejection, and plain-encoded fallback pages.

End-to-end hosted benchmark evidence is recorded in apache/datafusion#27312. With RLE dictionary reads enabled on both the exact parent and dependency head, total time across TPC-H, TPC-DS, and partitioned ClickBench improved by 22.40% (49.147s to 38.140s); partitioned ClickBench improved by 27.60%. All suites completed without query failures.

Are there any significant user-facing changes?

No.

@Rich-T-kid
Rich-T-kid marked this pull request as ready for review November 10, 2026 21:27
@Rich-T-kid
Rich-T-kid requested a review from etseidl November 10, 2026 21:27
@Rich-T-kid

Copy link
Copy Markdown
Contributor Author

@etseidl @adriangb PTAL

@adriangb adriangb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me. I checked interaction w/ the existing bugs where we don't validate data as utf8, this makes things no better or worse. It fixes the bug and I could find no regressions.

@adriangb adriangb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think one case is still open: plain-encoded pages read as Dict<_, Utf8>. Details and a suggested fix are inline. This is the dictionary-reader form of #11140. Could also be a followup.

End-to-end reproduction with datafusion-cli

DataFusion main does not yet ask the Parquet reader for Dictionary(Int32, Utf8) on a BINARY column. It reads Binary and casts, and the cast validates. To show the path DataFusion would use once it relies on this PR (see apache/datafusion#26976), I built datafusion-cli from DataFusion main with [patch.crates-io] pointing at this branch, plus one coercion arm in datafusion/datasource-parquet/src/schema_coercion.rs:

(Dictionary(_, v), Binary) if enable_rle && binary_to_string && **v == Utf8 => {
    Some(table_type.clone())
}
-- 2000 distinct values overflow a 64-byte dictionary page, so the writer
-- falls back to plain pages. The last value (0xFF) is not valid UTF-8.
COPY (
  SELECT CASE WHEN v = 2000 THEN decode('ff', 'hex')
              ELSE arrow_cast(CAST(v AS VARCHAR), 'Binary') END AS b
  FROM generate_series(1, 2000) t(v) ORDER BY v
) TO 'mre.parquet' STORED AS PARQUET
OPTIONS ('format.dictionary_page_size_limit' '64');

SET datafusion.execution.parquet.binary_as_string = true;
SET datafusion.execution.parquet.enable_rle_to_dictionary = true;

SELECT arrow_typeof(b) AS t, count(*) FROM 'mre.parquet' GROUP BY 1;
SELECT length(b) FROM 'mre.parquet' ORDER BY length(b) LIMIT 1;

On this branch, the scan returns Dictionary(Int32, Utf8) with no error. Printing the invalid value then aborts the process:

unsafe precondition(s) violated: hint::unreachable_unchecked must never be reached

With the inline suggestion applied, the same query fails cleanly:

Arrow error: Parquet argument error: External: Invalid argument error: Encountered non UTF-8 data: invalid utf-8 sequence of 1 bytes from index 6889

@Rich-T-kid

Copy link
Copy Markdown
Contributor Author

thanks for the review @adriangb, 9eccacf adds coverage for the utf8 validation you flagged as well as a regression test.

@etseidl etseidl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Rich-T-kid. I worked my way through the logic and it seems correct to me. I let my agent handle the test coverage and that was good 👍.

One nit my agent was concerned with is convert_dictionary_values doesn't work in the reverse direction (utf -> binary), but admits that's not an issue in actual practice, it's only reachable via the factory, not through ArrowReaderOptions::with_schema.

@Rich-T-kid

Copy link
Copy Markdown
Contributor Author

thanks @etseidl @adriangb

@Rich-T-kid
Rich-T-kid added this pull request to the merge queue Nov 13, 2026
Merged via the queue into apache:main with commit 2a007e8 Nov 13, 2026
20 checks passed
@Jefffrey Jefffrey added the bug label Nov 13, 2026
hassaanch23 added a commit to hassaanch23/arrow-rs that referenced this pull request Nov 13, 2026
apache#12161 decodes an unannotated BYTE_ARRAY dictionary column as Binary and
converts the values with StringArray::try_from_binary, which validates
UTF-8. That covers the dictionary reader this PR used to change, so the
merge takes main's byte_array_dictionary.rs unchanged. The remaining
changes are in the non-dictionary readers.

test_read_non_utf8_binary_as_utf8 now matches "non UTF-8 data", like
apache#12161's tests, because the dictionary path reports "Encountered non
UTF-8 data" and the decoders "encountered non UTF-8 data".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug parquet Changes to the parquet crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parquet reader returns Binary dictionary values when a supplied schema requests Utf8

4 participants