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
{{ message }}
Repository navigation
[Python] pyarrow.array silently rewrites dictionary types to use signed integers for indexes #37476
Describe the bug, including details regarding any error messages, version, and platform.
If you define a dictionary type to use an unsigned integer for its index type, and explicitly pass it to pyarrow.array, it'll get ignored and you'll find yourself with a signed index instead.
changed the title [-]pyarrow.array silently rewrites dictionary types to use signed integers for indexes[/-][+][Python] pyarrow.array silently rewrites dictionary types to use signed integers for indexes[/+]on Aug 31, 2023
Thanks for the report. I think the problem is that in the python->arrow conversion (python_to_arrow.cc), it is using a DictionaryBuilder under the hood, which is created with:
pool, dict_type.index_type(), dict_type.value_type(),
dictionary, /*exact_index_type=*/false, out};
return visitor.Make();
}
This is passing exact_index_type = False, and that essentially means that it will use an adaptive int builder (that starts with the bitwidth size you specified, but can still grow eg from int32 to int64 if needed).
Maybe one way to fix the signed vs unsigned change is to let it use a AdaptiveIntBuilder vs AdaptiveUIntBuilder, depending on the signedness of the original index type. That would preserve the signedness, but keep the ability to let the bitwidth grow if necessary to convert the data to a dictionary type.
Ran into this from the narwhals side: its Categorical maps to dictionary(uint32, string), so building one from Python values fails there. I opened #50474 before I spotted this issue, and I've closed that as a duplicate of this one.
One thing that might be worth adding here: it isn't only the signedness that gets dropped. The index type is really treated as a starting bit width, so even a signed type stops being honoured once the cardinality outgrows it:
That falls out of the same place @jorisvandenbossche pointed at: MakeDictionaryBuilder passes exact_index_type=false, and DictionaryBuilderCase::CreateFor() reduces the requested type to index_type->byte_width() and hands that to AdaptiveIntBuilder as a starting width. So the signedness is lost and the width is only a hint.
I'd like to try the fix along the lines Joris suggested (an adaptive unsigned index builder for unsigned index types, keeping the width adaptive). Two things I hit while reading around it:
AdaptiveUIntBuilder's constructors don't take the alignment argument that AdaptiveIntBuilder's do, so DictionaryBuilderBase<AdaptiveUIntBuilder, T> doesn't compile as it stands.
DictionaryConverter in util/converter.h hardcodes DictionaryBuilder<ValueType> and checked_casts to it, and that trait is shared with the R binding, so it looks like it needs an index-builder template parameter rather than a local change.
I had a go at this and landed somewhere different from what I wrote above.
The AdaptiveUIntBuilder route does not work. It makes MakeDictionaryBuilder return a different builder class, and util/converter.h, json/from_string.cc and the R binding all cast the result to DictionaryBuilder<T>. TestDictionaryUnifier.ChunkedArrayNestedDict, which builds a dictionary(uint32(), utf8()), dies on a checked_pointer_cast DCHECK as soon as you try it.
What works is keeping one builder class and preserving the requested signedness where the index type is reported. Indices are non-negative and the signed and unsigned types of a given width have the same layout, so it is value-preserving and free, and the width stays adaptive. Nothing outside builder_dict.h and builder.cc has to change, so R is fixed rather than broken.
One consequence: dictionary(uint64(), ...) then really does produce uint64 indices, and arrow_to_pandas.cc refuses to convert those, so to_pandas() starts raising where it used to work by accident on int32 indices. uint8, uint16 and uint32 are unaffected, and take, filter, cast, IPC, Parquet, the compute kernels and Acero all keep working.
Activity
[-]pyarrow.array silently rewrites dictionary types to use signed integers for indexes[/-][+][Python] pyarrow.array silently rewrites dictionary types to use signed integers for indexes[/+]jorisvandenbossche commentedon Aug 31, 2023
Thanks for the report. I think the problem is that in the python->arrow conversion (
python_to_arrow.cc), it is using a DictionaryBuilder under the hood, which is created with:arrow/cpp/src/arrow/builder.cc
Lines 312 to 320 in 9b6be29
This is passing
exact_index_type = False, and that essentially means that it will use an adaptive int builder (that starts with the bitwidth size you specified, but can still grow eg from int32 to int64 if needed).Maybe one way to fix the signed vs unsigned change is to let it use a AdaptiveIntBuilder vs AdaptiveUIntBuilder, depending on the signedness of the original index type. That would preserve the signedness, but keep the ability to let the bitwidth grow if necessary to convert the data to a dictionary type.
mkzung commentedon Jul 11, 2026
Ran into this from the narwhals side: its
Categoricalmaps todictionary(uint32, string), so building one from Python values fails there. I opened #50474 before I spotted this issue, and I've closed that as a duplicate of this one.One thing that might be worth adding here: it isn't only the signedness that gets dropped. The index type is really treated as a starting bit width, so even a signed type stops being honoured once the cardinality outgrows it:
That falls out of the same place @jorisvandenbossche pointed at:
MakeDictionaryBuilderpassesexact_index_type=false, andDictionaryBuilderCase::CreateFor()reduces the requested type toindex_type->byte_width()and hands that toAdaptiveIntBuilderas a starting width. So the signedness is lost and the width is only a hint.I'd like to try the fix along the lines Joris suggested (an adaptive unsigned index builder for unsigned index types, keeping the width adaptive). Two things I hit while reading around it:
AdaptiveUIntBuilder's constructors don't take thealignmentargument thatAdaptiveIntBuilder's do, soDictionaryBuilderBase<AdaptiveUIntBuilder, T>doesn't compile as it stands.DictionaryConverterinutil/converter.hhardcodesDictionaryBuilder<ValueType>andchecked_casts to it, and that trait is shared with the R binding, so it looks like it needs an index-builder template parameter rather than a local change.Happy to pick this up if nobody else is on it.
Versions: pyarrow 23.0.1, Python 3.10, macOS arm64.
mkzung commentedon Jul 11, 2026
take
mkzung commentedon Jul 11, 2026
I had a go at this and landed somewhere different from what I wrote above.
The AdaptiveUIntBuilder route does not work. It makes
MakeDictionaryBuilderreturn a different builder class, andutil/converter.h,json/from_string.ccand the R binding all cast the result toDictionaryBuilder<T>.TestDictionaryUnifier.ChunkedArrayNestedDict, which builds adictionary(uint32(), utf8()), dies on achecked_pointer_castDCHECK as soon as you try it.What works is keeping one builder class and preserving the requested signedness where the index type is reported. Indices are non-negative and the signed and unsigned types of a given width have the same layout, so it is value-preserving and free, and the width stays adaptive. Nothing outside
builder_dict.handbuilder.cchas to change, so R is fixed rather than broken.One consequence:
dictionary(uint64(), ...)then really does produce uint64 indices, andarrow_to_pandas.ccrefuses to convert those, soto_pandas()starts raising where it used to work by accident on int32 indices. uint8, uint16 and uint32 are unaffected, and take, filter, cast, IPC, Parquet, the compute kernels and Acero all keep working.PR shortly.
apacheGH-37476: address review comments
apacheGH-37476: address review round 2
GH-37476: [C++][Python] Preserve unsigned dictionary index types when…
pitrou commentedon Jul 28, 2026
Issue resolved by pull request 50475
#50475
apacheGH-50695: [C++] Use the full unsigned range for unsigned dictio…