Skip to content

GH-37476: [C++][Python] Preserve unsigned dictionary index types when building from values - #50475

Merged
pitrou merged 3 commits into
apache:mainfrom
mkzung:gh-37476-unsigned-dict-index
Jul 28, 2026
Merged

GH-37476: [C++][Python] Preserve unsigned dictionary index types when building from values#50475
pitrou merged 3 commits into
apache:mainfrom
mkzung:gh-37476-unsigned-dict-index

Conversation

@mkzung

@mkzung mkzung commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

An unsigned dictionary index type is silently replaced by the signed one of the same width:

>>> pa.array(["a", "b"], type=pa.dictionary(pa.uint32(), pa.string())).type
dictionary<values=string, indices=int32, ordered=0>

It also makes pa.chunked_array(values, dict_type) fail with Array chunks must all be same type, since the chunk built internally comes back a different type than requested.

DictionaryBuilderCase::CreateFor() reduces the requested index type to its byte width and hands that to AdaptiveIntBuilder, so everything but the width is dropped and the type is rebuilt from what the indices builder reports, which is always signed.

@jorisvandenbossche suggested AdaptiveUIntBuilder for unsigned types. That returns a different builder class, which util/converter.h, json/from_string.cc and the R binding all cast to DictionaryBuilder<T>, and TestDictionaryUnifier.ChunkedArrayNestedDict hits a checked_pointer_cast DCHECK. So this keeps one builder class and just reports the requested signedness. Indices are non-negative and same-width signed and unsigned types have the same layout, so it is value-preserving and free, and the width stays adaptive.

What changes are included in this PR?

DictionaryBuilderBase and its NullType specialization record the requested signedness and map the signed index type to the unsigned one of the same width in type(), FinishInternal() and FinishDelta(); CreateFor() passes it through. The mapping helper lives in builder.cc, not the header. util/converter.h, python_to_arrow.cc and r_to_arrow.cpp are untouched, so R is fixed rather than broken.

uint64 indices also convert to pandas now, mapped to int64 codes. That is safe because the indices are bounds-checked below the dictionary length, so they never reach the int64 limit; WriteIndicesUniform handles uint64 like it already handles uint32.

Are these changes tested?

Six tests in array_dict_test.cc cover the distinct paths: type and finish preservation across signed and unsigned, the width adapting while staying unsigned, the NullType builder, FinishDelta, the supplied-dictionary constructor and the exact-index builder. Three parametrized tests in test_array.py cover the eight index types, the width adapting and the uint64-to-pandas conversion, and test_dictionary_with_pandas now checks that conversion instead of the old error.

A uint8 grows to uint16 once more than 128 distinct values are added and stays unsigned; the underlying builder is signed, so it widens after 128 rather than the 256 a real uint8 could hold. arrow-array-test, arrow-ipc-read-write-test, arrow-compute-scalar-cast-test, arrow-c-bridge-test and the pyarrow test_array and test_pandas suites pass.

Are there any user-facing changes?

pa.array and pa.chunked_array return the requested unsigned index type. dictionary(uint64(), ...) produces real uint64 indices, and converting such an array to pandas now works where it used to raise. take, filter, cast, dictionary_decode, IPC and scalar access all keep the index type.

@mkzung

mkzung commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Friendly ping on this one. The workflow runs look like they're still waiting on
approval, so none of the C++ or Python jobs have actually executed here yet.

@jorisvandenbossche you diagnosed this back in 2023 and suggested letting it pick
AdaptiveIntBuilder vs AdaptiveUIntBuilder depending on the signedness of the original
index type, so you may want to see why I didn't go that way. It changes which class
MakeDictionaryBuilder returns, and util/converter.h, json/from_string.cc and the R
binding all cast that result to DictionaryBuilder.
TestDictionaryUnifier.ChunkedArrayNestedDict trips a checked_pointer_cast DCHECK
straight away. This keeps one builder class instead and preserves the requested
signedness where the index type is reported.

Happy to rebase if it's gone stale.

Comment thread cpp/src/arrow/array/array_dict_test.cc Outdated
}
}

TEST(TestDictionaryBuilderIndexType, TypePreservesSignedIndexType) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You could fold this test into the above, no need for duplicate code here IMHO.

Comment thread cpp/src/arrow/array/array_dict_test.cc Outdated
Comment on lines +1082 to +1083
std::shared_ptr<Array> result;
ASSERT_OK(builder.Finish(&result));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can use the Result-returning Finish version:

Suggested change
std::shared_ptr<Array> result;
ASSERT_OK(builder.Finish(&result));
ASSERT_OK_AND_ASSIGN(auto result, builder.Finish());

auto& builder = checked_cast<DictionaryBuilder<StringType>&>(*boxed_builder);

// More distinct values than a uint8 index start width accommodates.
for (int i = 0; i < 200; ++i) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm, is that right? A uint8 index should be able to represent 256 distinct dictionary entries (or 255 depending on how it's coded internally :-)).

Comment thread cpp/src/arrow/array/array_dict_test.cc Outdated
TEST(TestDictionaryBuilderIndexType, SuppliedDictionaryPreservesUnsignedIndexType) {
// The supplied-dictionary constructor starts the adaptive indices builder at its
// default width rather than at the requested one, so the requested *width* is not
// honoured on this path. That predates this change: a requested int32 reports int8

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"this change" will not be understandable to a future reader of this comment, can you reword it to be less context-dependent?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also, if we already have a test for this on the signed index side, I wonder what it brings to add this new test.

AIs tend to produce very verbose testing, which gives the illusion of better quality, but also makes the code less maintainable in the long term (why all those very similar test functions)?

Comment thread cpp/src/arrow/array/array_dict_test.cc Outdated
*checked_cast<const DictionaryType&>(*unsigned_type).index_type());

ASSERT_OK_AND_ASSIGN(auto signed_builder,
MakeDictionaryBuilder(dictionary(int32(), utf8()), dict_values));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So we're also testing for a signed index type here? Wasn't it already tested? If not, at least the test name and comment should be fixed.

Comment thread cpp/src/arrow/array/builder_dict.h Outdated
/// width have identical memory layout and reporting one as the other is
/// value-preserving (GH-37476). The width itself stays adaptive, exactly as it is
/// for signed index types; only the signedness of the requested type is preserved.
inline std::shared_ptr<DataType> MaybeUnsignedIndexType(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think this has to be inlined in this header file, as this function shouldn't be performance-critical.

Comment thread cpp/src/arrow/array/builder_dict.h Outdated
MemoryPool* pool = default_memory_pool(),
int64_t alignment = kDefaultBufferAlignment, bool ordered = false)
int64_t alignment = kDefaultBufferAlignment, bool ordered = false,
bool unsigned_index = false)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we call this use_unsigned_index?

Comment thread python/pyarrow/tests/test_array.py Outdated

@pytest.mark.parametrize("index_type", [pa.int8(), pa.int16(),
pa.int32(), pa.int64()])
def test_dictionary_array_signed_index_type_unchanged(index_type):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you fold this into the above test?

Comment thread python/pyarrow/tests/test_array.py Outdated
Comment on lines +4556 to +4558
def test_dictionary_array_index_width_still_adapts():
# The index width remains adaptive, as it is for signed index types; only
# the signedness of the requested index type is preserved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same comment wrt comments being too much context-dependent ("still adapts", "remains adaptive").

Also, can we check both signed and unsigned here?

Comment thread python/pyarrow/tests/test_array.py Outdated
Comment on lines +4568 to +4570
# silently downgraded to int64. Arrow deliberately does not support converting
# them to pandas (pandas categorical codes are signed), so that pre-existing
# limitation is now reachable, where the silent downgrade used to mask it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm, that sounds like a potential regression for third-party code. Can we support converting uint64 indices to Pandas?

You can take a look at CategoricalWriter in arrow_to_pandas.cc. It seems that WriteIndicesUniform is the main place that needs to be changed.

Consolidate the dictionary index-type tests down to the distinct code paths and
reword the context-dependent comments. Move MaybeUnsignedIndexType out of the
header into builder.cc and rename the flag to use_unsigned_index.

Support converting uint64 dictionary indices to pandas: they map to int64
categorical codes, which is safe because the indices are bounds-checked below the
dictionary length. This removes the previous TypeError.
@mkzung

mkzung commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I folded the index-type tests down to the distinct code paths - type and finish preservation now run in one loop over signed and unsigned, and I dropped the ordered and fixed-size-binary ones since they don't exercise a separate path. Switched to ASSERT_OK_AND_ASSIGN and reworded the context-dependent comments. Moved MaybeUnsignedIndexType into builder.cc and renamed the flag to use_unsigned_index.

On uint64 to pandas: I added support for it instead of keeping the error. uint64 indices map to int64 codes, which is safe because the indices are bounds-checked below the dictionary length, so they never reach the int64 limit. WriteIndicesUniform handles it the same way as uint32, and I updated test_dictionary_with_pandas to check the conversion instead of the raise.

On the missing top bit: you're right, the adaptive builder is signed so a uint8 index widens after 128 rather than 256. I left that as-is and noted it in the builder.cc comment - happy to change the builder to track the unsigned range if you'd prefer.

@pitrou pitrou left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the update, a couple minor additional suggestions

Comment thread cpp/src/arrow/builder.cc Outdated
case Type::INT64:
return ::arrow::uint64();
default:
return index_type;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we use arrow::Unreachable here? Or can the index_type argument actually be unsigned?

Comment thread cpp/src/arrow/array/array_dict_test.cc
Comment thread cpp/src/arrow/array/array_dict_test.cc
@github-actions github-actions Bot added awaiting committer review Awaiting committer review and removed awaiting review Awaiting review labels Jul 27, 2026
- MaybeUnsignedIndexType: the default switch case is unreachable (the adaptive
  index builder only produces signed int8/16/32/64), so mark it with
  arrow::Unreachable instead of returning the argument
- SuppliedDictionary/ExactIndexBuilder index-type tests: build the array and
  assert the finished array's index type, not just the builder's reported type
- rename WidthStillAdaptsWhenUnsigned -> WidthAdaptsWhenUnsigned (drop the
  context-dependent "Still")
@mkzung

mkzung commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, done:

  • MaybeUnsignedIndexType: the default case is unreachable (the adaptive builder only produces
    signed int8/16/32/64), so it's arrow::Unreachable now.
  • Both tests now build the array and check the finished array's index type, not just the
    builder's.
  • Renamed to WidthAdaptsWhenUnsigned.

The uint8-widens-at-128 bit is still a documented limitation; happy to do the unsigned-range
follow-up separately if you want it. The red R/Windows checks look like infra, not this change
(bioconductor was down for R, MinGW timed out in the deps).

@pitrou

pitrou commented Jul 28, 2026

Copy link
Copy Markdown
Member

The uint8-widens-at-128 bit is still a documented limitation; happy to do the unsigned-range
follow-up separately if you want it.

It's probably desirable though not critical, so feel free to work on it if you'd like to.

The uint8-widens-at-128 bit is still a documented limitation; happy to do the unsigned-range follow-up separately if you want it. The red R/Windows checks look like infra, not this change (bioconductor was down for R, MinGW timed out in the deps).

Probably, let's wait for the updated CI results.

@pitrou

pitrou commented Jul 28, 2026

Copy link
Copy Markdown
Member

The remaining CI failures are unrelated, I'll merge.

@conbench-apache-arrow

Copy link
Copy Markdown

After merging your PR, Conbench analyzed the 4 benchmarking runs that have been run so far on merge-commit bda98c4.

There were no benchmark performance regressions. 🎉

The full Conbench report has more details. It also includes information about 20 possible false positives for unstable benchmarks that are known to sometimes produce them.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants