GH-42018: [Python] Add NumPy StringDType -> Arrow support - #50951

Open
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype
Open

GH-42018: [Python] Add NumPy StringDType -> Arrow support#50951
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype

Conversation

@alippai

@alippaialippai commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

Implement #42018

What changes are included in this PR?

Conversion in numpy->arrow direction with multiple string types

Are these changes tested?

Two basic conversion tests added and one boundary that only string(), large_string(), string_view() is supported.

Are there any user-facing changes?

Yes, adds support to numpy.StringDType as source

cc @jorisvandenbossche as he opened the original issue

This PR was created using GPT-5.6-Sol-xhigh, every line read & reviewed by me.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum can I ask you for review?

@alippai
alippaiforce-pushed the gh-42018-string-dtype branch 2 times, most recently from 02275b9 to b527de1CompareAugust 22, 2026 03:52
@alippai
alippaiforce-pushed the gh-42018-string-dtype branch from b527de1 to 346e15fCompareAugust 22, 2026 04:16
@alippaialippai changed the title GH-42018: [Python] Add NumPy StringDType supportGH-42018: [Python] Add NumPy StringDType -> Arrow supportAug 22, 2026
@alippai

Copy link
Copy Markdown
ContributorAuthor

The commits are in complexity & speed order. They are supposed to be reviewable commit-by-commit.

@ngoldbaum

Copy link
Copy Markdown

I'll do a pass over this next week. Thank you for moving this forward.

@ngoldbaumngoldbaum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The two CI failures are unrelated, I think.

Before this can be merged, you should also explicitly mention this feature in docs/source/python/numpy.rst. Right now it just says arrow supports "structured dtypes or strings", but NumPy's string support has grown more complicated since that was written and it's worth updating now to say something like "structured dtypes and both fixed-width, and variable-width strings". Maybe do a further pass to see if there are other spots that could mention this.

I carefully reviewed this for lock discipline and didn't spot any bugs. I do not see any places where possibly-blocking APIs are called while the allocator lock is held. Also the way this is currently structured, it can't conflict with the GIL because the conversion happens in code that explicitly does not hold the GIL.

I have some minor comments below but I think this is in mostly good shape from the perspective of NumPy C API use.

Comment on lines +715 to +734
const char* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
const bool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}

constexpr int64_t kBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
const int64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};

for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
constchar* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {
constchar* data = PyArray_BYTES(arr_);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
// Hold the allocator lock for the whole conversion to ensure a consistent snapshot
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

This way you hold the allocator lock for the minimum amount of code, I also added a comment explaining that there is a mutex here so a reader knows to watch out for deadlock risks.

template <typename T>
Status NumPyConverter::VisitString(T* builder) {
if (dtype_->type_num == NPY_VSTRING) {
return VisitStringDType(builder);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
return VisitStringDType(builder);
// Acquires a lock so this must not be moved inside the gil_lock section below
return VisitStringDType(builder);


if (dtype_->type_num == NPY_VSTRING && !is_string_or_string_view(type_->id())) {
return Status::TypeError(
"NumPy StringDType can only be converted to Arrow string types");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Might be nice to see the type that was passed in:

Suggested change
"NumPy StringDType can only be converted to Arrow string types");
"NumPy StringDType can only be converted to Arrow string types, got",
type_->ToString());

TO_ARROW_TYPE_CASE(FLOAT64, float64);
TO_ARROW_TYPE_CASE(STRING, binary);
TO_ARROW_TYPE_CASE(UNICODE, utf8);
TO_ARROW_TYPE_CASE(VSTRING, utf8);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

My AI model thinks that doing this has an unintended, untested side effect: pa.array([np.array([...], dtype="T")]) now infers as a list of strings and converts through the per-element sequence fallback in python_to_arrow.cc:

if (PyArray_DESCR(ndarray)->type_num != NUMPY_TYPE) { \
returnthis->value_converter_->Extend(value, size); \
} \

That's fine, it just needs a test to cover it.

#include <cstring>
#include <limits>
#include <memory>
#include <numeric>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a public header so this shouldn't be deleted. Deleting this also caused churn in this PR in other compilation units, which newly add #include <numeric> to work around this. The same could happen in user code including this header.



@pytest.mark.numpy
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])
@pytest.mark.parametrize('string_type', [None, pa.large_string(), pa.string_view()])

Let's include the default string() type too.


arrow_arr = pa.array(arr, type=string_type)
arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == ["some", None, "strings"]

@ngoldbaumngoldbaumAug 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?


arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == values

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The first test improves coverage for your changes to ChunkedBinaryBuilder. The second test covers the untested case I referred to in my comment above where a list of ndarrays gains new inference behavior.

Suggested change
@pytest.mark.numpy
deftest_array_from_numpy_string_dtype_chunking(numpy_string_dtype):
# Three 6 MiB values in one batch must split across the 16 MiB
# per-chunk limit of the string() path.
values= ["x"* (6*1024*1024)] *3
arr=np.array(values, dtype=numpy_string_dtype())
result=pa.array(arr, type=pa.string())
assertisinstance(result, pa.ChunkedArray)
assertresult.num_chunks==2
result.validate(full=True)
assertresult.to_pylist() ==values
@pytest.mark.numpy
deftest_array_from_list_of_numpy_string_dtype_arrays(numpy_string_dtype):
values= [["a", "bb"], ["ccc"]]
arrays= [np.array(v, dtype=numpy_string_dtype()) forvinvalues]
result=pa.array(arrays)
assertresult.type==pa.list_(pa.string())
assertresult.to_pylist() ==values

@github-actionsgithub-actionsBot added awaiting committer review Awaiting committer review and removed awaiting review Awaiting review labels Aug 26, 2026
Comment threadcpp/src/arrow/acero/hash_join_benchmark.cc
@jorisvandenbossche

Copy link
Copy Markdown
Member

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@github-actionsgithub-actionsBot added awaiting changes Awaiting changes and removed awaiting committer review Awaiting committer review labels Aug 26, 2026
@jorisvandenbossche

Copy link
Copy Markdown
Member

Inline comment of @ngoldbaum (moving it here it make it more prominent and not hidden in a potentially outdated/collapsed review comment):

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?

I think this is the main API design question to discuss: the numpy StringDtype has a configurable missing value sentinel model, while Arrow has no such option and just has nulls (through the bitmask).
So that means it is not really possible to fully "faithfully" translate those missing semantics in the numpy->arrow conversion. Either:

  • we use the actual placeholder value (or at least if it is a string) as the resulting string value in the Arrow string array. That preserves the value, but looses the fact that it is missing
  • we translate the placeholder values to nulls (what this PR currently does). That preserves the missingness, but looses the information about the original placeholder value used.

So we always loose something (unless we would create an extension type ..). Personally, my feeling is that preserving "missingness" is the most relevant.

FWIW, this also means that a fully faithful roundtrip (once the conversion from arrow -> numpy exists) is also not possible out of the box, only if the user specifies the resulting numpy dtype (which has the information about which placeholder to use)

@alippai

Copy link
Copy Markdown
ContributorAuthor

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@jorisvandenbossche this PR has a similar simpler version as the first commit. I opportunistically added a few batching, performance optimizations in the following 2-3rd commits (without benchmarks, I don’t know how trivial is this considered and I don’t have a representative machine for this right now).

@ngoldbaum

Copy link
Copy Markdown

this PR has a similar simpler version as the first commit

Maybe as a first pass you could try to just merge the simpler version. Then later in future PRs you could add the optimizations, along with benchmarks to justify them.

@ngoldbaum

Copy link
Copy Markdown

@alippai gentle ping here. Just in case it's helpful: I'm happy to take over shepherding this feature as I can work on it under funded time.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum I can work on this later this weekend only. Feel free to either take the first commit or start from scratch

@ngoldbaum

Copy link
Copy Markdown

I went ahead and opened #51157 which I marked as superseding this one.

Sign up for freeto 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.

3 participants

@alippai@ngoldbaum@jorisvandenbossche
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

GH-42018: [Python] Add NumPy StringDType -> Arrow support - #50951

Open
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype
Open

GH-42018: [Python] Add NumPy StringDType -> Arrow support#50951
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype

Conversation

@alippai

@alippaialippai commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

Implement #42018

What changes are included in this PR?

Conversion in numpy->arrow direction with multiple string types

Are these changes tested?

Two basic conversion tests added and one boundary that only string(), large_string(), string_view() is supported.

Are there any user-facing changes?

Yes, adds support to numpy.StringDType as source

cc @jorisvandenbossche as he opened the original issue

This PR was created using GPT-5.6-Sol-xhigh, every line read & reviewed by me.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum can I ask you for review?

@alippai
alippaiforce-pushed the gh-42018-string-dtype branch 2 times, most recently from 02275b9 to b527de1CompareAugust 22, 2026 03:52
@alippai
alippaiforce-pushed the gh-42018-string-dtype branch from b527de1 to 346e15fCompareAugust 22, 2026 04:16
@alippaialippai changed the title GH-42018: [Python] Add NumPy StringDType supportGH-42018: [Python] Add NumPy StringDType -> Arrow supportAug 22, 2026
@alippai

Copy link
Copy Markdown
ContributorAuthor

The commits are in complexity & speed order. They are supposed to be reviewable commit-by-commit.

@ngoldbaum

Copy link
Copy Markdown

I'll do a pass over this next week. Thank you for moving this forward.

@ngoldbaumngoldbaum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The two CI failures are unrelated, I think.

Before this can be merged, you should also explicitly mention this feature in docs/source/python/numpy.rst. Right now it just says arrow supports "structured dtypes or strings", but NumPy's string support has grown more complicated since that was written and it's worth updating now to say something like "structured dtypes and both fixed-width, and variable-width strings". Maybe do a further pass to see if there are other spots that could mention this.

I carefully reviewed this for lock discipline and didn't spot any bugs. I do not see any places where possibly-blocking APIs are called while the allocator lock is held. Also the way this is currently structured, it can't conflict with the GIL because the conversion happens in code that explicitly does not hold the GIL.

I have some minor comments below but I think this is in mostly good shape from the perspective of NumPy C API use.

Comment on lines +715 to +734
const char* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
const bool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}

constexpr int64_t kBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
const int64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};

for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
constchar* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {
constchar* data = PyArray_BYTES(arr_);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
// Hold the allocator lock for the whole conversion to ensure a consistent snapshot
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

This way you hold the allocator lock for the minimum amount of code, I also added a comment explaining that there is a mutex here so a reader knows to watch out for deadlock risks.

template <typename T>
Status NumPyConverter::VisitString(T* builder) {
if (dtype_->type_num == NPY_VSTRING) {
return VisitStringDType(builder);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
return VisitStringDType(builder);
// Acquires a lock so this must not be moved inside the gil_lock section below
return VisitStringDType(builder);


if (dtype_->type_num == NPY_VSTRING && !is_string_or_string_view(type_->id())) {
return Status::TypeError(
"NumPy StringDType can only be converted to Arrow string types");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Might be nice to see the type that was passed in:

Suggested change
"NumPy StringDType can only be converted to Arrow string types");
"NumPy StringDType can only be converted to Arrow string types, got",
type_->ToString());

TO_ARROW_TYPE_CASE(FLOAT64, float64);
TO_ARROW_TYPE_CASE(STRING, binary);
TO_ARROW_TYPE_CASE(UNICODE, utf8);
TO_ARROW_TYPE_CASE(VSTRING, utf8);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

My AI model thinks that doing this has an unintended, untested side effect: pa.array([np.array([...], dtype="T")]) now infers as a list of strings and converts through the per-element sequence fallback in python_to_arrow.cc:

if (PyArray_DESCR(ndarray)->type_num != NUMPY_TYPE) { \
returnthis->value_converter_->Extend(value, size); \
} \

That's fine, it just needs a test to cover it.

#include <cstring>
#include <limits>
#include <memory>
#include <numeric>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a public header so this shouldn't be deleted. Deleting this also caused churn in this PR in other compilation units, which newly add #include <numeric> to work around this. The same could happen in user code including this header.



@pytest.mark.numpy
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])
@pytest.mark.parametrize('string_type', [None, pa.large_string(), pa.string_view()])

Let's include the default string() type too.


arrow_arr = pa.array(arr, type=string_type)
arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == ["some", None, "strings"]

@ngoldbaumngoldbaumAug 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?


arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == values

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The first test improves coverage for your changes to ChunkedBinaryBuilder. The second test covers the untested case I referred to in my comment above where a list of ndarrays gains new inference behavior.

Suggested change
@pytest.mark.numpy
deftest_array_from_numpy_string_dtype_chunking(numpy_string_dtype):
# Three 6 MiB values in one batch must split across the 16 MiB
# per-chunk limit of the string() path.
values= ["x"* (6*1024*1024)] *3
arr=np.array(values, dtype=numpy_string_dtype())
result=pa.array(arr, type=pa.string())
assertisinstance(result, pa.ChunkedArray)
assertresult.num_chunks==2
result.validate(full=True)
assertresult.to_pylist() ==values
@pytest.mark.numpy
deftest_array_from_list_of_numpy_string_dtype_arrays(numpy_string_dtype):
values= [["a", "bb"], ["ccc"]]
arrays= [np.array(v, dtype=numpy_string_dtype()) forvinvalues]
result=pa.array(arrays)
assertresult.type==pa.list_(pa.string())
assertresult.to_pylist() ==values

@github-actionsgithub-actionsBot added awaiting committer review Awaiting committer review and removed awaiting review Awaiting review labels Aug 26, 2026
Comment threadcpp/src/arrow/acero/hash_join_benchmark.cc
@jorisvandenbossche

Copy link
Copy Markdown
Member

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@github-actionsgithub-actionsBot added awaiting changes Awaiting changes and removed awaiting committer review Awaiting committer review labels Aug 26, 2026
@jorisvandenbossche

Copy link
Copy Markdown
Member

Inline comment of @ngoldbaum (moving it here it make it more prominent and not hidden in a potentially outdated/collapsed review comment):

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?

I think this is the main API design question to discuss: the numpy StringDtype has a configurable missing value sentinel model, while Arrow has no such option and just has nulls (through the bitmask).
So that means it is not really possible to fully "faithfully" translate those missing semantics in the numpy->arrow conversion. Either:

  • we use the actual placeholder value (or at least if it is a string) as the resulting string value in the Arrow string array. That preserves the value, but looses the fact that it is missing
  • we translate the placeholder values to nulls (what this PR currently does). That preserves the missingness, but looses the information about the original placeholder value used.

So we always loose something (unless we would create an extension type ..). Personally, my feeling is that preserving "missingness" is the most relevant.

FWIW, this also means that a fully faithful roundtrip (once the conversion from arrow -> numpy exists) is also not possible out of the box, only if the user specifies the resulting numpy dtype (which has the information about which placeholder to use)

@alippai

Copy link
Copy Markdown
ContributorAuthor

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@jorisvandenbossche this PR has a similar simpler version as the first commit. I opportunistically added a few batching, performance optimizations in the following 2-3rd commits (without benchmarks, I don’t know how trivial is this considered and I don’t have a representative machine for this right now).

@ngoldbaum

Copy link
Copy Markdown

this PR has a similar simpler version as the first commit

Maybe as a first pass you could try to just merge the simpler version. Then later in future PRs you could add the optimizations, along with benchmarks to justify them.

@ngoldbaum

Copy link
Copy Markdown

@alippai gentle ping here. Just in case it's helpful: I'm happy to take over shepherding this feature as I can work on it under funded time.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum I can work on this later this weekend only. Feel free to either take the first commit or start from scratch

@ngoldbaum

Copy link
Copy Markdown

I went ahead and opened #51157 which I marked as superseding this one.

Sign up for freeto 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.

3 participants

@alippai@ngoldbaum@jorisvandenbossche
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

GH-42018: [Python] Add NumPy StringDType -> Arrow support - #50951

Open
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype
Open

GH-42018: [Python] Add NumPy StringDType -> Arrow support#50951
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype

Conversation

@alippai

@alippaialippai commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

Implement #42018

What changes are included in this PR?

Conversion in numpy->arrow direction with multiple string types

Are these changes tested?

Two basic conversion tests added and one boundary that only string(), large_string(), string_view() is supported.

Are there any user-facing changes?

Yes, adds support to numpy.StringDType as source

cc @jorisvandenbossche as he opened the original issue

This PR was created using GPT-5.6-Sol-xhigh, every line read & reviewed by me.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum can I ask you for review?

@alippai
alippaiforce-pushed the gh-42018-string-dtype branch 2 times, most recently from 02275b9 to b527de1CompareAugust 22, 2026 03:52
@alippai
alippaiforce-pushed the gh-42018-string-dtype branch from b527de1 to 346e15fCompareAugust 22, 2026 04:16
@alippaialippai changed the title GH-42018: [Python] Add NumPy StringDType supportGH-42018: [Python] Add NumPy StringDType -> Arrow supportAug 22, 2026
@alippai

Copy link
Copy Markdown
ContributorAuthor

The commits are in complexity & speed order. They are supposed to be reviewable commit-by-commit.

@ngoldbaum

Copy link
Copy Markdown

I'll do a pass over this next week. Thank you for moving this forward.

@ngoldbaumngoldbaum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The two CI failures are unrelated, I think.

Before this can be merged, you should also explicitly mention this feature in docs/source/python/numpy.rst. Right now it just says arrow supports "structured dtypes or strings", but NumPy's string support has grown more complicated since that was written and it's worth updating now to say something like "structured dtypes and both fixed-width, and variable-width strings". Maybe do a further pass to see if there are other spots that could mention this.

I carefully reviewed this for lock discipline and didn't spot any bugs. I do not see any places where possibly-blocking APIs are called while the allocator lock is held. Also the way this is currently structured, it can't conflict with the GIL because the conversion happens in code that explicitly does not hold the GIL.

I have some minor comments below but I think this is in mostly good shape from the perspective of NumPy C API use.

Comment on lines +715 to +734
const char* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
const bool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}

constexpr int64_t kBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
const int64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};

for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
constchar* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {
constchar* data = PyArray_BYTES(arr_);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
// Hold the allocator lock for the whole conversion to ensure a consistent snapshot
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

This way you hold the allocator lock for the minimum amount of code, I also added a comment explaining that there is a mutex here so a reader knows to watch out for deadlock risks.

template <typename T>
Status NumPyConverter::VisitString(T* builder) {
if (dtype_->type_num == NPY_VSTRING) {
return VisitStringDType(builder);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
return VisitStringDType(builder);
// Acquires a lock so this must not be moved inside the gil_lock section below
return VisitStringDType(builder);


if (dtype_->type_num == NPY_VSTRING && !is_string_or_string_view(type_->id())) {
return Status::TypeError(
"NumPy StringDType can only be converted to Arrow string types");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Might be nice to see the type that was passed in:

Suggested change
"NumPy StringDType can only be converted to Arrow string types");
"NumPy StringDType can only be converted to Arrow string types, got",
type_->ToString());

TO_ARROW_TYPE_CASE(FLOAT64, float64);
TO_ARROW_TYPE_CASE(STRING, binary);
TO_ARROW_TYPE_CASE(UNICODE, utf8);
TO_ARROW_TYPE_CASE(VSTRING, utf8);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

My AI model thinks that doing this has an unintended, untested side effect: pa.array([np.array([...], dtype="T")]) now infers as a list of strings and converts through the per-element sequence fallback in python_to_arrow.cc:

if (PyArray_DESCR(ndarray)->type_num != NUMPY_TYPE) { \
returnthis->value_converter_->Extend(value, size); \
} \

That's fine, it just needs a test to cover it.

#include <cstring>
#include <limits>
#include <memory>
#include <numeric>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a public header so this shouldn't be deleted. Deleting this also caused churn in this PR in other compilation units, which newly add #include <numeric> to work around this. The same could happen in user code including this header.



@pytest.mark.numpy
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])
@pytest.mark.parametrize('string_type', [None, pa.large_string(), pa.string_view()])

Let's include the default string() type too.


arrow_arr = pa.array(arr, type=string_type)
arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == ["some", None, "strings"]

@ngoldbaumngoldbaumAug 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?


arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == values

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The first test improves coverage for your changes to ChunkedBinaryBuilder. The second test covers the untested case I referred to in my comment above where a list of ndarrays gains new inference behavior.

Suggested change
@pytest.mark.numpy
deftest_array_from_numpy_string_dtype_chunking(numpy_string_dtype):
# Three 6 MiB values in one batch must split across the 16 MiB
# per-chunk limit of the string() path.
values= ["x"* (6*1024*1024)] *3
arr=np.array(values, dtype=numpy_string_dtype())
result=pa.array(arr, type=pa.string())
assertisinstance(result, pa.ChunkedArray)
assertresult.num_chunks==2
result.validate(full=True)
assertresult.to_pylist() ==values
@pytest.mark.numpy
deftest_array_from_list_of_numpy_string_dtype_arrays(numpy_string_dtype):
values= [["a", "bb"], ["ccc"]]
arrays= [np.array(v, dtype=numpy_string_dtype()) forvinvalues]
result=pa.array(arrays)
assertresult.type==pa.list_(pa.string())
assertresult.to_pylist() ==values

@github-actionsgithub-actionsBot added awaiting committer review Awaiting committer review and removed awaiting review Awaiting review labels Aug 26, 2026
Comment threadcpp/src/arrow/acero/hash_join_benchmark.cc
@jorisvandenbossche

Copy link
Copy Markdown
Member

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@github-actionsgithub-actionsBot added awaiting changes Awaiting changes and removed awaiting committer review Awaiting committer review labels Aug 26, 2026
@jorisvandenbossche

Copy link
Copy Markdown
Member

Inline comment of @ngoldbaum (moving it here it make it more prominent and not hidden in a potentially outdated/collapsed review comment):

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?

I think this is the main API design question to discuss: the numpy StringDtype has a configurable missing value sentinel model, while Arrow has no such option and just has nulls (through the bitmask).
So that means it is not really possible to fully "faithfully" translate those missing semantics in the numpy->arrow conversion. Either:

  • we use the actual placeholder value (or at least if it is a string) as the resulting string value in the Arrow string array. That preserves the value, but looses the fact that it is missing
  • we translate the placeholder values to nulls (what this PR currently does). That preserves the missingness, but looses the information about the original placeholder value used.

So we always loose something (unless we would create an extension type ..). Personally, my feeling is that preserving "missingness" is the most relevant.

FWIW, this also means that a fully faithful roundtrip (once the conversion from arrow -> numpy exists) is also not possible out of the box, only if the user specifies the resulting numpy dtype (which has the information about which placeholder to use)

@alippai

Copy link
Copy Markdown
ContributorAuthor

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@jorisvandenbossche this PR has a similar simpler version as the first commit. I opportunistically added a few batching, performance optimizations in the following 2-3rd commits (without benchmarks, I don’t know how trivial is this considered and I don’t have a representative machine for this right now).

@ngoldbaum

Copy link
Copy Markdown

this PR has a similar simpler version as the first commit

Maybe as a first pass you could try to just merge the simpler version. Then later in future PRs you could add the optimizations, along with benchmarks to justify them.

@ngoldbaum

Copy link
Copy Markdown

@alippai gentle ping here. Just in case it's helpful: I'm happy to take over shepherding this feature as I can work on it under funded time.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum I can work on this later this weekend only. Feel free to either take the first commit or start from scratch

@ngoldbaum

Copy link
Copy Markdown

I went ahead and opened #51157 which I marked as superseding this one.

Sign up for freeto 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.

3 participants

@alippai@ngoldbaum@jorisvandenbossche
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

GH-42018: [Python] Add NumPy StringDType -> Arrow support - #50951

Open
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype
Open

GH-42018: [Python] Add NumPy StringDType -> Arrow support#50951
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype

Conversation

@alippai

@alippaialippai commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

Implement #42018

What changes are included in this PR?

Conversion in numpy->arrow direction with multiple string types

Are these changes tested?

Two basic conversion tests added and one boundary that only string(), large_string(), string_view() is supported.

Are there any user-facing changes?

Yes, adds support to numpy.StringDType as source

cc @jorisvandenbossche as he opened the original issue

This PR was created using GPT-5.6-Sol-xhigh, every line read & reviewed by me.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum can I ask you for review?

@alippai
alippaiforce-pushed the gh-42018-string-dtype branch 2 times, most recently from 02275b9 to b527de1CompareAugust 22, 2026 03:52
@alippai
alippaiforce-pushed the gh-42018-string-dtype branch from b527de1 to 346e15fCompareAugust 22, 2026 04:16
@alippaialippai changed the title GH-42018: [Python] Add NumPy StringDType supportGH-42018: [Python] Add NumPy StringDType -> Arrow supportAug 22, 2026
@alippai

Copy link
Copy Markdown
ContributorAuthor

The commits are in complexity & speed order. They are supposed to be reviewable commit-by-commit.

@ngoldbaum

Copy link
Copy Markdown

I'll do a pass over this next week. Thank you for moving this forward.

@ngoldbaumngoldbaum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The two CI failures are unrelated, I think.

Before this can be merged, you should also explicitly mention this feature in docs/source/python/numpy.rst. Right now it just says arrow supports "structured dtypes or strings", but NumPy's string support has grown more complicated since that was written and it's worth updating now to say something like "structured dtypes and both fixed-width, and variable-width strings". Maybe do a further pass to see if there are other spots that could mention this.

I carefully reviewed this for lock discipline and didn't spot any bugs. I do not see any places where possibly-blocking APIs are called while the allocator lock is held. Also the way this is currently structured, it can't conflict with the GIL because the conversion happens in code that explicitly does not hold the GIL.

I have some minor comments below but I think this is in mostly good shape from the perspective of NumPy C API use.

Comment on lines +715 to +734
const char* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
const bool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}

constexpr int64_t kBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
const int64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};

for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
constchar* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {
constchar* data = PyArray_BYTES(arr_);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
// Hold the allocator lock for the whole conversion to ensure a consistent snapshot
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

This way you hold the allocator lock for the minimum amount of code, I also added a comment explaining that there is a mutex here so a reader knows to watch out for deadlock risks.

template <typename T>
Status NumPyConverter::VisitString(T* builder) {
if (dtype_->type_num == NPY_VSTRING) {
return VisitStringDType(builder);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
return VisitStringDType(builder);
// Acquires a lock so this must not be moved inside the gil_lock section below
return VisitStringDType(builder);


if (dtype_->type_num == NPY_VSTRING && !is_string_or_string_view(type_->id())) {
return Status::TypeError(
"NumPy StringDType can only be converted to Arrow string types");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Might be nice to see the type that was passed in:

Suggested change
"NumPy StringDType can only be converted to Arrow string types");
"NumPy StringDType can only be converted to Arrow string types, got",
type_->ToString());

TO_ARROW_TYPE_CASE(FLOAT64, float64);
TO_ARROW_TYPE_CASE(STRING, binary);
TO_ARROW_TYPE_CASE(UNICODE, utf8);
TO_ARROW_TYPE_CASE(VSTRING, utf8);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

My AI model thinks that doing this has an unintended, untested side effect: pa.array([np.array([...], dtype="T")]) now infers as a list of strings and converts through the per-element sequence fallback in python_to_arrow.cc:

if (PyArray_DESCR(ndarray)->type_num != NUMPY_TYPE) { \
returnthis->value_converter_->Extend(value, size); \
} \

That's fine, it just needs a test to cover it.

#include <cstring>
#include <limits>
#include <memory>
#include <numeric>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a public header so this shouldn't be deleted. Deleting this also caused churn in this PR in other compilation units, which newly add #include <numeric> to work around this. The same could happen in user code including this header.



@pytest.mark.numpy
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])
@pytest.mark.parametrize('string_type', [None, pa.large_string(), pa.string_view()])

Let's include the default string() type too.


arrow_arr = pa.array(arr, type=string_type)
arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == ["some", None, "strings"]

@ngoldbaumngoldbaumAug 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?


arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == values

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The first test improves coverage for your changes to ChunkedBinaryBuilder. The second test covers the untested case I referred to in my comment above where a list of ndarrays gains new inference behavior.

Suggested change
@pytest.mark.numpy
deftest_array_from_numpy_string_dtype_chunking(numpy_string_dtype):
# Three 6 MiB values in one batch must split across the 16 MiB
# per-chunk limit of the string() path.
values= ["x"* (6*1024*1024)] *3
arr=np.array(values, dtype=numpy_string_dtype())
result=pa.array(arr, type=pa.string())
assertisinstance(result, pa.ChunkedArray)
assertresult.num_chunks==2
result.validate(full=True)
assertresult.to_pylist() ==values
@pytest.mark.numpy
deftest_array_from_list_of_numpy_string_dtype_arrays(numpy_string_dtype):
values= [["a", "bb"], ["ccc"]]
arrays= [np.array(v, dtype=numpy_string_dtype()) forvinvalues]
result=pa.array(arrays)
assertresult.type==pa.list_(pa.string())
assertresult.to_pylist() ==values

@github-actionsgithub-actionsBot added awaiting committer review Awaiting committer review and removed awaiting review Awaiting review labels Aug 26, 2026
Comment threadcpp/src/arrow/acero/hash_join_benchmark.cc
@jorisvandenbossche

Copy link
Copy Markdown
Member

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@github-actionsgithub-actionsBot added awaiting changes Awaiting changes and removed awaiting committer review Awaiting committer review labels Aug 26, 2026
@jorisvandenbossche

Copy link
Copy Markdown
Member

Inline comment of @ngoldbaum (moving it here it make it more prominent and not hidden in a potentially outdated/collapsed review comment):

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?

I think this is the main API design question to discuss: the numpy StringDtype has a configurable missing value sentinel model, while Arrow has no such option and just has nulls (through the bitmask).
So that means it is not really possible to fully "faithfully" translate those missing semantics in the numpy->arrow conversion. Either:

  • we use the actual placeholder value (or at least if it is a string) as the resulting string value in the Arrow string array. That preserves the value, but looses the fact that it is missing
  • we translate the placeholder values to nulls (what this PR currently does). That preserves the missingness, but looses the information about the original placeholder value used.

So we always loose something (unless we would create an extension type ..). Personally, my feeling is that preserving "missingness" is the most relevant.

FWIW, this also means that a fully faithful roundtrip (once the conversion from arrow -> numpy exists) is also not possible out of the box, only if the user specifies the resulting numpy dtype (which has the information about which placeholder to use)

@alippai

Copy link
Copy Markdown
ContributorAuthor

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@jorisvandenbossche this PR has a similar simpler version as the first commit. I opportunistically added a few batching, performance optimizations in the following 2-3rd commits (without benchmarks, I don’t know how trivial is this considered and I don’t have a representative machine for this right now).

@ngoldbaum

Copy link
Copy Markdown

this PR has a similar simpler version as the first commit

Maybe as a first pass you could try to just merge the simpler version. Then later in future PRs you could add the optimizations, along with benchmarks to justify them.

@ngoldbaum

Copy link
Copy Markdown

@alippai gentle ping here. Just in case it's helpful: I'm happy to take over shepherding this feature as I can work on it under funded time.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum I can work on this later this weekend only. Feel free to either take the first commit or start from scratch

@ngoldbaum

Copy link
Copy Markdown

I went ahead and opened #51157 which I marked as superseding this one.

Sign up for freeto 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.

3 participants

@alippai@ngoldbaum@jorisvandenbossche
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

GH-42018: [Python] Add NumPy StringDType -> Arrow support - #50951

Open
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype
Open

GH-42018: [Python] Add NumPy StringDType -> Arrow support#50951
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype

Conversation

@alippai

@alippaialippai commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

Implement #42018

What changes are included in this PR?

Conversion in numpy->arrow direction with multiple string types

Are these changes tested?

Two basic conversion tests added and one boundary that only string(), large_string(), string_view() is supported.

Are there any user-facing changes?

Yes, adds support to numpy.StringDType as source

cc @jorisvandenbossche as he opened the original issue

This PR was created using GPT-5.6-Sol-xhigh, every line read & reviewed by me.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum can I ask you for review?

@alippai
alippaiforce-pushed the gh-42018-string-dtype branch 2 times, most recently from 02275b9 to b527de1CompareAugust 22, 2026 03:52
@alippai
alippaiforce-pushed the gh-42018-string-dtype branch from b527de1 to 346e15fCompareAugust 22, 2026 04:16
@alippaialippai changed the title GH-42018: [Python] Add NumPy StringDType supportGH-42018: [Python] Add NumPy StringDType -> Arrow supportAug 22, 2026
@alippai

Copy link
Copy Markdown
ContributorAuthor

The commits are in complexity & speed order. They are supposed to be reviewable commit-by-commit.

@ngoldbaum

Copy link
Copy Markdown

I'll do a pass over this next week. Thank you for moving this forward.

@ngoldbaumngoldbaum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The two CI failures are unrelated, I think.

Before this can be merged, you should also explicitly mention this feature in docs/source/python/numpy.rst. Right now it just says arrow supports "structured dtypes or strings", but NumPy's string support has grown more complicated since that was written and it's worth updating now to say something like "structured dtypes and both fixed-width, and variable-width strings". Maybe do a further pass to see if there are other spots that could mention this.

I carefully reviewed this for lock discipline and didn't spot any bugs. I do not see any places where possibly-blocking APIs are called while the allocator lock is held. Also the way this is currently structured, it can't conflict with the GIL because the conversion happens in code that explicitly does not hold the GIL.

I have some minor comments below but I think this is in mostly good shape from the perspective of NumPy C API use.

Comment on lines +715 to +734
const char* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
const bool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}

constexpr int64_t kBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
const int64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};

for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
constchar* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {
constchar* data = PyArray_BYTES(arr_);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
// Hold the allocator lock for the whole conversion to ensure a consistent snapshot
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

This way you hold the allocator lock for the minimum amount of code, I also added a comment explaining that there is a mutex here so a reader knows to watch out for deadlock risks.

template <typename T>
Status NumPyConverter::VisitString(T* builder) {
if (dtype_->type_num == NPY_VSTRING) {
return VisitStringDType(builder);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
return VisitStringDType(builder);
// Acquires a lock so this must not be moved inside the gil_lock section below
return VisitStringDType(builder);


if (dtype_->type_num == NPY_VSTRING && !is_string_or_string_view(type_->id())) {
return Status::TypeError(
"NumPy StringDType can only be converted to Arrow string types");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Might be nice to see the type that was passed in:

Suggested change
"NumPy StringDType can only be converted to Arrow string types");
"NumPy StringDType can only be converted to Arrow string types, got",
type_->ToString());

TO_ARROW_TYPE_CASE(FLOAT64, float64);
TO_ARROW_TYPE_CASE(STRING, binary);
TO_ARROW_TYPE_CASE(UNICODE, utf8);
TO_ARROW_TYPE_CASE(VSTRING, utf8);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

My AI model thinks that doing this has an unintended, untested side effect: pa.array([np.array([...], dtype="T")]) now infers as a list of strings and converts through the per-element sequence fallback in python_to_arrow.cc:

if (PyArray_DESCR(ndarray)->type_num != NUMPY_TYPE) { \
returnthis->value_converter_->Extend(value, size); \
} \

That's fine, it just needs a test to cover it.

#include <cstring>
#include <limits>
#include <memory>
#include <numeric>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a public header so this shouldn't be deleted. Deleting this also caused churn in this PR in other compilation units, which newly add #include <numeric> to work around this. The same could happen in user code including this header.



@pytest.mark.numpy
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])
@pytest.mark.parametrize('string_type', [None, pa.large_string(), pa.string_view()])

Let's include the default string() type too.


arrow_arr = pa.array(arr, type=string_type)
arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == ["some", None, "strings"]

@ngoldbaumngoldbaumAug 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?


arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == values

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The first test improves coverage for your changes to ChunkedBinaryBuilder. The second test covers the untested case I referred to in my comment above where a list of ndarrays gains new inference behavior.

Suggested change
@pytest.mark.numpy
deftest_array_from_numpy_string_dtype_chunking(numpy_string_dtype):
# Three 6 MiB values in one batch must split across the 16 MiB
# per-chunk limit of the string() path.
values= ["x"* (6*1024*1024)] *3
arr=np.array(values, dtype=numpy_string_dtype())
result=pa.array(arr, type=pa.string())
assertisinstance(result, pa.ChunkedArray)
assertresult.num_chunks==2
result.validate(full=True)
assertresult.to_pylist() ==values
@pytest.mark.numpy
deftest_array_from_list_of_numpy_string_dtype_arrays(numpy_string_dtype):
values= [["a", "bb"], ["ccc"]]
arrays= [np.array(v, dtype=numpy_string_dtype()) forvinvalues]
result=pa.array(arrays)
assertresult.type==pa.list_(pa.string())
assertresult.to_pylist() ==values

@github-actionsgithub-actionsBot added awaiting committer review Awaiting committer review and removed awaiting review Awaiting review labels Aug 26, 2026
Comment threadcpp/src/arrow/acero/hash_join_benchmark.cc
@jorisvandenbossche

Copy link
Copy Markdown
Member

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@github-actionsgithub-actionsBot added awaiting changes Awaiting changes and removed awaiting committer review Awaiting committer review labels Aug 26, 2026
@jorisvandenbossche

Copy link
Copy Markdown
Member

Inline comment of @ngoldbaum (moving it here it make it more prominent and not hidden in a potentially outdated/collapsed review comment):

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?

I think this is the main API design question to discuss: the numpy StringDtype has a configurable missing value sentinel model, while Arrow has no such option and just has nulls (through the bitmask).
So that means it is not really possible to fully "faithfully" translate those missing semantics in the numpy->arrow conversion. Either:

  • we use the actual placeholder value (or at least if it is a string) as the resulting string value in the Arrow string array. That preserves the value, but looses the fact that it is missing
  • we translate the placeholder values to nulls (what this PR currently does). That preserves the missingness, but looses the information about the original placeholder value used.

So we always loose something (unless we would create an extension type ..). Personally, my feeling is that preserving "missingness" is the most relevant.

FWIW, this also means that a fully faithful roundtrip (once the conversion from arrow -> numpy exists) is also not possible out of the box, only if the user specifies the resulting numpy dtype (which has the information about which placeholder to use)

@alippai

Copy link
Copy Markdown
ContributorAuthor

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@jorisvandenbossche this PR has a similar simpler version as the first commit. I opportunistically added a few batching, performance optimizations in the following 2-3rd commits (without benchmarks, I don’t know how trivial is this considered and I don’t have a representative machine for this right now).

@ngoldbaum

Copy link
Copy Markdown

this PR has a similar simpler version as the first commit

Maybe as a first pass you could try to just merge the simpler version. Then later in future PRs you could add the optimizations, along with benchmarks to justify them.

@ngoldbaum

Copy link
Copy Markdown

@alippai gentle ping here. Just in case it's helpful: I'm happy to take over shepherding this feature as I can work on it under funded time.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum I can work on this later this weekend only. Feel free to either take the first commit or start from scratch

@ngoldbaum

Copy link
Copy Markdown

I went ahead and opened #51157 which I marked as superseding this one.

Sign up for freeto 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.

3 participants

@alippai@ngoldbaum@jorisvandenbossche
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

GH-42018: [Python] Add NumPy StringDType -> Arrow support - #50951

Open
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype
Open

GH-42018: [Python] Add NumPy StringDType -> Arrow support#50951
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype

Conversation

@alippai

@alippaialippai commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

Implement #42018

What changes are included in this PR?

Conversion in numpy->arrow direction with multiple string types

Are these changes tested?

Two basic conversion tests added and one boundary that only string(), large_string(), string_view() is supported.

Are there any user-facing changes?

Yes, adds support to numpy.StringDType as source

cc @jorisvandenbossche as he opened the original issue

This PR was created using GPT-5.6-Sol-xhigh, every line read & reviewed by me.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum can I ask you for review?

@alippai
alippaiforce-pushed the gh-42018-string-dtype branch 2 times, most recently from 02275b9 to b527de1CompareAugust 22, 2026 03:52
@alippai
alippaiforce-pushed the gh-42018-string-dtype branch from b527de1 to 346e15fCompareAugust 22, 2026 04:16
@alippaialippai changed the title GH-42018: [Python] Add NumPy StringDType supportGH-42018: [Python] Add NumPy StringDType -> Arrow supportAug 22, 2026
@alippai

Copy link
Copy Markdown
ContributorAuthor

The commits are in complexity & speed order. They are supposed to be reviewable commit-by-commit.

@ngoldbaum

Copy link
Copy Markdown

I'll do a pass over this next week. Thank you for moving this forward.

@ngoldbaumngoldbaum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The two CI failures are unrelated, I think.

Before this can be merged, you should also explicitly mention this feature in docs/source/python/numpy.rst. Right now it just says arrow supports "structured dtypes or strings", but NumPy's string support has grown more complicated since that was written and it's worth updating now to say something like "structured dtypes and both fixed-width, and variable-width strings". Maybe do a further pass to see if there are other spots that could mention this.

I carefully reviewed this for lock discipline and didn't spot any bugs. I do not see any places where possibly-blocking APIs are called while the allocator lock is held. Also the way this is currently structured, it can't conflict with the GIL because the conversion happens in code that explicitly does not hold the GIL.

I have some minor comments below but I think this is in mostly good shape from the perspective of NumPy C API use.

Comment on lines +715 to +734
const char* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
const bool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}

constexpr int64_t kBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
const int64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};

for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
constchar* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {
constchar* data = PyArray_BYTES(arr_);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
// Hold the allocator lock for the whole conversion to ensure a consistent snapshot
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

This way you hold the allocator lock for the minimum amount of code, I also added a comment explaining that there is a mutex here so a reader knows to watch out for deadlock risks.

template <typename T>
Status NumPyConverter::VisitString(T* builder) {
if (dtype_->type_num == NPY_VSTRING) {
return VisitStringDType(builder);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
return VisitStringDType(builder);
// Acquires a lock so this must not be moved inside the gil_lock section below
return VisitStringDType(builder);


if (dtype_->type_num == NPY_VSTRING && !is_string_or_string_view(type_->id())) {
return Status::TypeError(
"NumPy StringDType can only be converted to Arrow string types");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Might be nice to see the type that was passed in:

Suggested change
"NumPy StringDType can only be converted to Arrow string types");
"NumPy StringDType can only be converted to Arrow string types, got",
type_->ToString());

TO_ARROW_TYPE_CASE(FLOAT64, float64);
TO_ARROW_TYPE_CASE(STRING, binary);
TO_ARROW_TYPE_CASE(UNICODE, utf8);
TO_ARROW_TYPE_CASE(VSTRING, utf8);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

My AI model thinks that doing this has an unintended, untested side effect: pa.array([np.array([...], dtype="T")]) now infers as a list of strings and converts through the per-element sequence fallback in python_to_arrow.cc:

if (PyArray_DESCR(ndarray)->type_num != NUMPY_TYPE) { \
returnthis->value_converter_->Extend(value, size); \
} \

That's fine, it just needs a test to cover it.

#include <cstring>
#include <limits>
#include <memory>
#include <numeric>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a public header so this shouldn't be deleted. Deleting this also caused churn in this PR in other compilation units, which newly add #include <numeric> to work around this. The same could happen in user code including this header.



@pytest.mark.numpy
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])
@pytest.mark.parametrize('string_type', [None, pa.large_string(), pa.string_view()])

Let's include the default string() type too.


arrow_arr = pa.array(arr, type=string_type)
arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == ["some", None, "strings"]

@ngoldbaumngoldbaumAug 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?


arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == values

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The first test improves coverage for your changes to ChunkedBinaryBuilder. The second test covers the untested case I referred to in my comment above where a list of ndarrays gains new inference behavior.

Suggested change
@pytest.mark.numpy
deftest_array_from_numpy_string_dtype_chunking(numpy_string_dtype):
# Three 6 MiB values in one batch must split across the 16 MiB
# per-chunk limit of the string() path.
values= ["x"* (6*1024*1024)] *3
arr=np.array(values, dtype=numpy_string_dtype())
result=pa.array(arr, type=pa.string())
assertisinstance(result, pa.ChunkedArray)
assertresult.num_chunks==2
result.validate(full=True)
assertresult.to_pylist() ==values
@pytest.mark.numpy
deftest_array_from_list_of_numpy_string_dtype_arrays(numpy_string_dtype):
values= [["a", "bb"], ["ccc"]]
arrays= [np.array(v, dtype=numpy_string_dtype()) forvinvalues]
result=pa.array(arrays)
assertresult.type==pa.list_(pa.string())
assertresult.to_pylist() ==values

@github-actionsgithub-actionsBot added awaiting committer review Awaiting committer review and removed awaiting review Awaiting review labels Aug 26, 2026
Comment threadcpp/src/arrow/acero/hash_join_benchmark.cc
@jorisvandenbossche

Copy link
Copy Markdown
Member

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@github-actionsgithub-actionsBot added awaiting changes Awaiting changes and removed awaiting committer review Awaiting committer review labels Aug 26, 2026
@jorisvandenbossche

Copy link
Copy Markdown
Member

Inline comment of @ngoldbaum (moving it here it make it more prominent and not hidden in a potentially outdated/collapsed review comment):

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?

I think this is the main API design question to discuss: the numpy StringDtype has a configurable missing value sentinel model, while Arrow has no such option and just has nulls (through the bitmask).
So that means it is not really possible to fully "faithfully" translate those missing semantics in the numpy->arrow conversion. Either:

  • we use the actual placeholder value (or at least if it is a string) as the resulting string value in the Arrow string array. That preserves the value, but looses the fact that it is missing
  • we translate the placeholder values to nulls (what this PR currently does). That preserves the missingness, but looses the information about the original placeholder value used.

So we always loose something (unless we would create an extension type ..). Personally, my feeling is that preserving "missingness" is the most relevant.

FWIW, this also means that a fully faithful roundtrip (once the conversion from arrow -> numpy exists) is also not possible out of the box, only if the user specifies the resulting numpy dtype (which has the information about which placeholder to use)

@alippai

Copy link
Copy Markdown
ContributorAuthor

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@jorisvandenbossche this PR has a similar simpler version as the first commit. I opportunistically added a few batching, performance optimizations in the following 2-3rd commits (without benchmarks, I don’t know how trivial is this considered and I don’t have a representative machine for this right now).

@ngoldbaum

Copy link
Copy Markdown

this PR has a similar simpler version as the first commit

Maybe as a first pass you could try to just merge the simpler version. Then later in future PRs you could add the optimizations, along with benchmarks to justify them.

@ngoldbaum

Copy link
Copy Markdown

@alippai gentle ping here. Just in case it's helpful: I'm happy to take over shepherding this feature as I can work on it under funded time.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum I can work on this later this weekend only. Feel free to either take the first commit or start from scratch

@ngoldbaum

Copy link
Copy Markdown

I went ahead and opened #51157 which I marked as superseding this one.

Sign up for freeto 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.

3 participants

@alippai@ngoldbaum@jorisvandenbossche
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

GH-42018: [Python] Add NumPy StringDType -> Arrow support - #50951

Open
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype
Open

GH-42018: [Python] Add NumPy StringDType -> Arrow support#50951
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype

Conversation

@alippai

@alippaialippai commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

Implement #42018

What changes are included in this PR?

Conversion in numpy->arrow direction with multiple string types

Are these changes tested?

Two basic conversion tests added and one boundary that only string(), large_string(), string_view() is supported.

Are there any user-facing changes?

Yes, adds support to numpy.StringDType as source

cc @jorisvandenbossche as he opened the original issue

This PR was created using GPT-5.6-Sol-xhigh, every line read & reviewed by me.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum can I ask you for review?

@alippai
alippaiforce-pushed the gh-42018-string-dtype branch 2 times, most recently from 02275b9 to b527de1CompareAugust 22, 2026 03:52
@alippai
alippaiforce-pushed the gh-42018-string-dtype branch from b527de1 to 346e15fCompareAugust 22, 2026 04:16
@alippaialippai changed the title GH-42018: [Python] Add NumPy StringDType supportGH-42018: [Python] Add NumPy StringDType -> Arrow supportAug 22, 2026
@alippai

Copy link
Copy Markdown
ContributorAuthor

The commits are in complexity & speed order. They are supposed to be reviewable commit-by-commit.

@ngoldbaum

Copy link
Copy Markdown

I'll do a pass over this next week. Thank you for moving this forward.

@ngoldbaumngoldbaum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The two CI failures are unrelated, I think.

Before this can be merged, you should also explicitly mention this feature in docs/source/python/numpy.rst. Right now it just says arrow supports "structured dtypes or strings", but NumPy's string support has grown more complicated since that was written and it's worth updating now to say something like "structured dtypes and both fixed-width, and variable-width strings". Maybe do a further pass to see if there are other spots that could mention this.

I carefully reviewed this for lock discipline and didn't spot any bugs. I do not see any places where possibly-blocking APIs are called while the allocator lock is held. Also the way this is currently structured, it can't conflict with the GIL because the conversion happens in code that explicitly does not hold the GIL.

I have some minor comments below but I think this is in mostly good shape from the perspective of NumPy C API use.

Comment on lines +715 to +734
const char* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
const bool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}

constexpr int64_t kBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
const int64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};

for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
constchar* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {
constchar* data = PyArray_BYTES(arr_);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
// Hold the allocator lock for the whole conversion to ensure a consistent snapshot
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

This way you hold the allocator lock for the minimum amount of code, I also added a comment explaining that there is a mutex here so a reader knows to watch out for deadlock risks.

template <typename T>
Status NumPyConverter::VisitString(T* builder) {
if (dtype_->type_num == NPY_VSTRING) {
return VisitStringDType(builder);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
return VisitStringDType(builder);
// Acquires a lock so this must not be moved inside the gil_lock section below
return VisitStringDType(builder);


if (dtype_->type_num == NPY_VSTRING && !is_string_or_string_view(type_->id())) {
return Status::TypeError(
"NumPy StringDType can only be converted to Arrow string types");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Might be nice to see the type that was passed in:

Suggested change
"NumPy StringDType can only be converted to Arrow string types");
"NumPy StringDType can only be converted to Arrow string types, got",
type_->ToString());

TO_ARROW_TYPE_CASE(FLOAT64, float64);
TO_ARROW_TYPE_CASE(STRING, binary);
TO_ARROW_TYPE_CASE(UNICODE, utf8);
TO_ARROW_TYPE_CASE(VSTRING, utf8);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

My AI model thinks that doing this has an unintended, untested side effect: pa.array([np.array([...], dtype="T")]) now infers as a list of strings and converts through the per-element sequence fallback in python_to_arrow.cc:

if (PyArray_DESCR(ndarray)->type_num != NUMPY_TYPE) { \
returnthis->value_converter_->Extend(value, size); \
} \

That's fine, it just needs a test to cover it.

#include <cstring>
#include <limits>
#include <memory>
#include <numeric>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a public header so this shouldn't be deleted. Deleting this also caused churn in this PR in other compilation units, which newly add #include <numeric> to work around this. The same could happen in user code including this header.



@pytest.mark.numpy
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])
@pytest.mark.parametrize('string_type', [None, pa.large_string(), pa.string_view()])

Let's include the default string() type too.


arrow_arr = pa.array(arr, type=string_type)
arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == ["some", None, "strings"]

@ngoldbaumngoldbaumAug 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?


arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == values

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The first test improves coverage for your changes to ChunkedBinaryBuilder. The second test covers the untested case I referred to in my comment above where a list of ndarrays gains new inference behavior.

Suggested change
@pytest.mark.numpy
deftest_array_from_numpy_string_dtype_chunking(numpy_string_dtype):
# Three 6 MiB values in one batch must split across the 16 MiB
# per-chunk limit of the string() path.
values= ["x"* (6*1024*1024)] *3
arr=np.array(values, dtype=numpy_string_dtype())
result=pa.array(arr, type=pa.string())
assertisinstance(result, pa.ChunkedArray)
assertresult.num_chunks==2
result.validate(full=True)
assertresult.to_pylist() ==values
@pytest.mark.numpy
deftest_array_from_list_of_numpy_string_dtype_arrays(numpy_string_dtype):
values= [["a", "bb"], ["ccc"]]
arrays= [np.array(v, dtype=numpy_string_dtype()) forvinvalues]
result=pa.array(arrays)
assertresult.type==pa.list_(pa.string())
assertresult.to_pylist() ==values

@github-actionsgithub-actionsBot added awaiting committer review Awaiting committer review and removed awaiting review Awaiting review labels Aug 26, 2026
Comment threadcpp/src/arrow/acero/hash_join_benchmark.cc
@jorisvandenbossche

Copy link
Copy Markdown
Member

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@github-actionsgithub-actionsBot added awaiting changes Awaiting changes and removed awaiting committer review Awaiting committer review labels Aug 26, 2026
@jorisvandenbossche

Copy link
Copy Markdown
Member

Inline comment of @ngoldbaum (moving it here it make it more prominent and not hidden in a potentially outdated/collapsed review comment):

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?

I think this is the main API design question to discuss: the numpy StringDtype has a configurable missing value sentinel model, while Arrow has no such option and just has nulls (through the bitmask).
So that means it is not really possible to fully "faithfully" translate those missing semantics in the numpy->arrow conversion. Either:

  • we use the actual placeholder value (or at least if it is a string) as the resulting string value in the Arrow string array. That preserves the value, but looses the fact that it is missing
  • we translate the placeholder values to nulls (what this PR currently does). That preserves the missingness, but looses the information about the original placeholder value used.

So we always loose something (unless we would create an extension type ..). Personally, my feeling is that preserving "missingness" is the most relevant.

FWIW, this also means that a fully faithful roundtrip (once the conversion from arrow -> numpy exists) is also not possible out of the box, only if the user specifies the resulting numpy dtype (which has the information about which placeholder to use)

@alippai

Copy link
Copy Markdown
ContributorAuthor

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@jorisvandenbossche this PR has a similar simpler version as the first commit. I opportunistically added a few batching, performance optimizations in the following 2-3rd commits (without benchmarks, I don’t know how trivial is this considered and I don’t have a representative machine for this right now).

@ngoldbaum

Copy link
Copy Markdown

this PR has a similar simpler version as the first commit

Maybe as a first pass you could try to just merge the simpler version. Then later in future PRs you could add the optimizations, along with benchmarks to justify them.

@ngoldbaum

Copy link
Copy Markdown

@alippai gentle ping here. Just in case it's helpful: I'm happy to take over shepherding this feature as I can work on it under funded time.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum I can work on this later this weekend only. Feel free to either take the first commit or start from scratch

@ngoldbaum

Copy link
Copy Markdown

I went ahead and opened #51157 which I marked as superseding this one.

Sign up for freeto 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.

3 participants

@alippai@ngoldbaum@jorisvandenbossche
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

GH-42018: [Python] Add NumPy StringDType -> Arrow support - #50951

Open
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype
Open

GH-42018: [Python] Add NumPy StringDType -> Arrow support#50951
alippai wants to merge 4 commits into
apache:mainfrom
alippai:gh-42018-string-dtype

Conversation

@alippai

@alippaialippai commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

Implement #42018

What changes are included in this PR?

Conversion in numpy->arrow direction with multiple string types

Are these changes tested?

Two basic conversion tests added and one boundary that only string(), large_string(), string_view() is supported.

Are there any user-facing changes?

Yes, adds support to numpy.StringDType as source

cc @jorisvandenbossche as he opened the original issue

This PR was created using GPT-5.6-Sol-xhigh, every line read & reviewed by me.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum can I ask you for review?

@alippai
alippaiforce-pushed the gh-42018-string-dtype branch 2 times, most recently from 02275b9 to b527de1CompareAugust 22, 2026 03:52
@alippai
alippaiforce-pushed the gh-42018-string-dtype branch from b527de1 to 346e15fCompareAugust 22, 2026 04:16
@alippaialippai changed the title GH-42018: [Python] Add NumPy StringDType supportGH-42018: [Python] Add NumPy StringDType -> Arrow supportAug 22, 2026
@alippai

Copy link
Copy Markdown
ContributorAuthor

The commits are in complexity & speed order. They are supposed to be reviewable commit-by-commit.

@ngoldbaum

Copy link
Copy Markdown

I'll do a pass over this next week. Thank you for moving this forward.

@ngoldbaumngoldbaum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The two CI failures are unrelated, I think.

Before this can be merged, you should also explicitly mention this feature in docs/source/python/numpy.rst. Right now it just says arrow supports "structured dtypes or strings", but NumPy's string support has grown more complicated since that was written and it's worth updating now to say something like "structured dtypes and both fixed-width, and variable-width strings". Maybe do a further pass to see if there are other spots that could mention this.

I carefully reviewed this for lock discipline and didn't spot any bugs. I do not see any places where possibly-blocking APIs are called while the allocator lock is held. Also the way this is currently structured, it can't conflict with the GIL because the conversion happens in code that explicitly does not hold the GIL.

I have some minor comments below but I think this is in mostly good shape from the perspective of NumPy C API use.

Comment on lines +715 to +734
const char* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
const bool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}

constexpr int64_t kBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
const int64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};

for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
constchar* data = PyArray_BYTES(arr_);
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {
constchar* data = PyArray_BYTES(arr_);
constbool has_mask = mask_ != nullptr;
Ndarray1DIndexer<uint8_t> mask_values;
if (has_mask) {
mask_values = Ndarray1DIndexer<uint8_t>(mask_);
}
constexprint64_tkBatchSize = 4096;
// NpyString_load returns borrowed views that remain valid while the allocator is
// locked.
constint64_t batch_capacity = std::min(length_, kBatchSize);
std::vector<std::string_view> values(batch_capacity);
std::vector<uint8_t> valid(batch_capacity);
npy_static_string value{};
// Hold the allocator lock for the whole conversion to ensure a consistent snapshot
auto* allocator =
NpyString_acquire_allocator(reinterpret_cast<PyArray_StringDTypeObject*>(dtype_));
std::unique_ptr<npy_string_allocator, decltype(&NpyString_release_allocator)>
allocator_guard(allocator, &NpyString_release_allocator);
for (int64_t offset = 0; offset < length_; offset += kBatchSize) {

This way you hold the allocator lock for the minimum amount of code, I also added a comment explaining that there is a mutex here so a reader knows to watch out for deadlock risks.

template <typename T>
Status NumPyConverter::VisitString(T* builder) {
if (dtype_->type_num == NPY_VSTRING) {
return VisitStringDType(builder);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
return VisitStringDType(builder);
// Acquires a lock so this must not be moved inside the gil_lock section below
return VisitStringDType(builder);


if (dtype_->type_num == NPY_VSTRING && !is_string_or_string_view(type_->id())) {
return Status::TypeError(
"NumPy StringDType can only be converted to Arrow string types");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Might be nice to see the type that was passed in:

Suggested change
"NumPy StringDType can only be converted to Arrow string types");
"NumPy StringDType can only be converted to Arrow string types, got",
type_->ToString());

TO_ARROW_TYPE_CASE(FLOAT64, float64);
TO_ARROW_TYPE_CASE(STRING, binary);
TO_ARROW_TYPE_CASE(UNICODE, utf8);
TO_ARROW_TYPE_CASE(VSTRING, utf8);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

My AI model thinks that doing this has an unintended, untested side effect: pa.array([np.array([...], dtype="T")]) now infers as a list of strings and converts through the per-element sequence fallback in python_to_arrow.cc:

if (PyArray_DESCR(ndarray)->type_num != NUMPY_TYPE) { \
returnthis->value_converter_->Extend(value, size); \
} \

That's fine, it just needs a test to cover it.

#include <cstring>
#include <limits>
#include <memory>
#include <numeric>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a public header so this shouldn't be deleted. Deleting this also caused churn in this PR in other compilation units, which newly add #include <numeric> to work around this. The same could happen in user code including this header.



@pytest.mark.numpy
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
@pytest.mark.parametrize('string_type', [pa.large_string(), pa.string_view()])
@pytest.mark.parametrize('string_type', [None, pa.large_string(), pa.string_view()])

Let's include the default string() type too.


arrow_arr = pa.array(arr, type=string_type)
arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == ["some", None, "strings"]

@ngoldbaumngoldbaumAug 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?


arrow_arr.validate(full=True)
assert arrow_arr.to_pylist() == values

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The first test improves coverage for your changes to ChunkedBinaryBuilder. The second test covers the untested case I referred to in my comment above where a list of ndarrays gains new inference behavior.

Suggested change
@pytest.mark.numpy
deftest_array_from_numpy_string_dtype_chunking(numpy_string_dtype):
# Three 6 MiB values in one batch must split across the 16 MiB
# per-chunk limit of the string() path.
values= ["x"* (6*1024*1024)] *3
arr=np.array(values, dtype=numpy_string_dtype())
result=pa.array(arr, type=pa.string())
assertisinstance(result, pa.ChunkedArray)
assertresult.num_chunks==2
result.validate(full=True)
assertresult.to_pylist() ==values
@pytest.mark.numpy
deftest_array_from_list_of_numpy_string_dtype_arrays(numpy_string_dtype):
values= [["a", "bb"], ["ccc"]]
arrays= [np.array(v, dtype=numpy_string_dtype()) forvinvalues]
result=pa.array(arrays)
assertresult.type==pa.list_(pa.string())
assertresult.to_pylist() ==values

@github-actionsgithub-actionsBot added awaiting committer review Awaiting committer review and removed awaiting review Awaiting review labels Aug 26, 2026
Comment threadcpp/src/arrow/acero/hash_join_benchmark.cc
@jorisvandenbossche

Copy link
Copy Markdown
Member

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@github-actionsgithub-actionsBot added awaiting changes Awaiting changes and removed awaiting committer review Awaiting committer review labels Aug 26, 2026
@jorisvandenbossche

Copy link
Copy Markdown
Member

Inline comment of @ngoldbaum (moving it here it make it more prominent and not hidden in a potentially outdated/collapsed review comment):

Why is the output missing sentinel None for all input na_object choices, in particular for strings?

Note that this also disagrees with NumPy:

>>> np.array(["hello", "__placeholder__", "world"], dtype="T").tolist()
['hello', '__placeholder__', 'world']

Is there a reason why you can't more faithfully translate NumPy's string missing data semantics?

I think this is the main API design question to discuss: the numpy StringDtype has a configurable missing value sentinel model, while Arrow has no such option and just has nulls (through the bitmask).
So that means it is not really possible to fully "faithfully" translate those missing semantics in the numpy->arrow conversion. Either:

  • we use the actual placeholder value (or at least if it is a string) as the resulting string value in the Arrow string array. That preserves the value, but looses the fact that it is missing
  • we translate the placeholder values to nulls (what this PR currently does). That preserves the missingness, but looses the information about the original placeholder value used.

So we always loose something (unless we would create an extension type ..). Personally, my feeling is that preserving "missingness" is the most relevant.

FWIW, this also means that a fully faithful roundtrip (once the conversion from arrow -> numpy exists) is also not possible out of the box, only if the user specifies the resulting numpy dtype (which has the information about which placeholder to use)

@alippai

Copy link
Copy Markdown
ContributorAuthor

@alippai your previous PR #48391 did not need changes to the Builder code, while now there seems to be a significant addition there. Can you provide some context regarding the different implementation strategy?

@jorisvandenbossche this PR has a similar simpler version as the first commit. I opportunistically added a few batching, performance optimizations in the following 2-3rd commits (without benchmarks, I don’t know how trivial is this considered and I don’t have a representative machine for this right now).

@ngoldbaum

Copy link
Copy Markdown

this PR has a similar simpler version as the first commit

Maybe as a first pass you could try to just merge the simpler version. Then later in future PRs you could add the optimizations, along with benchmarks to justify them.

@ngoldbaum

Copy link
Copy Markdown

@alippai gentle ping here. Just in case it's helpful: I'm happy to take over shepherding this feature as I can work on it under funded time.

@alippai

Copy link
Copy Markdown
ContributorAuthor

@ngoldbaum I can work on this later this weekend only. Feel free to either take the first commit or start from scratch

@ngoldbaum

Copy link
Copy Markdown

I went ahead and opened #51157 which I marked as superseding this one.

Sign up for freeto 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.

3 participants

@alippai@ngoldbaum@jorisvandenbossche