Repository: pydata/xarray

Issue:
# Zarr chunks ignored when reading arrays with `StringDType`

### What happened?

When opening a Zarr array created with a NumPy 2 `StringDType` the Xarray DataArray doesn't have the same chunks as the underlying Zarr array.

### What did you expect to happen?

The Xarray chunking should be the same as the Zarr chunking. In the MVCE below, the chunking is the same if using a fixed-length unicode dtype (`dtype="U"`).

### Minimal Complete Verifiable Example

```Python
# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "xarray[complete]@git+https://github.com/pydata/xarray.git@main",
#   "zarr",
# ]
# ///
#
# This script automatically imports the development branch of xarray to check for issues.
# Please delete this header if you have _not_ tested this script with `uv run`!

import numpy as np
import xarray as xr
import zarr
xr.show_versions()

# create chunked string array using zarr
root = zarr.create_group(store="g")
data = np.array([
    ["a", "b", "c", "d"],
    ["e", "f", "g", "h"],
    ["i", "j", "k", "l"],
    ["m", "n", "o", "p"],
], dtype="T")
a = root.create_array(name="a", data=data, chunks=(2, 2), dimension_names=["x", "y"])

# open with xarray and check chunk sizes
x = xr.open_zarr("g", consolidated=False)
assert x["a"].chunks == ((2, 2), (2, 2))  # fails
```

### Steps to reproduce

_No response_

### MVCE confirmation

- [x] Minimal example — the example is as focused as reasonably possible to demonstrate the underlying issue in xarray.
- [x] Complete example — the example is self-contained, including all data and the text of any traceback.
- [x] Verifiable example — the example copy & pastes into an IPython prompt or [Binder notebook](https://mybinder.org/v2/gh/pydata/xarray/main?urlpath=lab/tree/doc/examples/blank_template.ipynb), returning the result.
- [x] New issue — a search of GitHub Issues suggests this is not a duplicate.
- [x] Recent environment — the issue occurs with the latest version of xarray and its dependencies.

### Relevant log output

```Python

```

### Anything else we need to know?

_No response_

### Environment

<details>



</details>


Implementation patch:
diff --git a/xarray/backends/zarr.py b/xarray/backends/zarr.py
index 8fcc968ca67..af2aad34d5e 100644
--- a/xarray/backends/zarr.py
+++ b/xarray/backends/zarr.py
@@ -206,6 +206,10 @@ def __init__(self, zarr_array):
             not _zarr_v3()
             and self._array.filters is not None
             and any(filt.codec_id == "vlen-utf8" for filt in self._array.filters)
+        ) or (
+            _zarr_v3()
+            and self._array.serializer
+            and self._array.serializer.to_dict()["name"] == "vlen-utf8"
         ):
             dtype = coding.strings.create_vlen_dtype(str)
         else:
diff --git a/xarray/core/duck_array_ops.py b/xarray/core/duck_array_ops.py
index 6739f2118ee..2377e6b01d0 100644
--- a/xarray/core/duck_array_ops.py
+++ b/xarray/core/duck_array_ops.py
@@ -26,6 +26,7 @@
 
 from xarray.compat import dask_array_compat, dask_array_ops
 from xarray.compat.array_api_compat import get_array_namespace
+from xarray.compat.npcompat import HAS_STRING_DTYPE
 from xarray.core import dtypes, nputils
 from xarray.core.extension_array import (
     PandasExtensionArray,
@@ -175,9 +176,15 @@ def isnull(data):
         # note: must check timedelta64 before integers, because currently
         # timedelta64 inherits from np.integer
         return isnat(data)
+    elif HAS_STRING_DTYPE and isinstance(scalar_type, np.dtypes.StringDType):
+        # na is settable, but it defaults to an empty string
+        na_object = getattr(scalar_type, "na_object", "")
+        if isna(na_object):
+            return xp.isnan(data)
+        else:
+            return data == na_object
     elif dtypes.isdtype(scalar_type, ("real floating", "complex floating"), xp=xp):
         # float types use NaN for null
-        xp = get_array_namespace(data)
         return xp.isnan(data)
     elif dtypes.isdtype(scalar_type, ("bool", "integral"), xp=xp) or (
         isinstance(scalar_type, np.dtype)


Test patch:
diff --git a/xarray/tests/test_backends.py b/xarray/tests/test_backends.py
index ed86979c73e..3d694d7791a 100644
--- a/xarray/tests/test_backends.py
+++ b/xarray/tests/test_backends.py
@@ -58,6 +58,7 @@
 from xarray.coding.cftime_offsets import date_range
 from xarray.coding.strings import check_vlen_dtype, create_vlen_dtype
 from xarray.coding.variables import SerializationWarning
+from xarray.compat.npcompat import HAS_STRING_DTYPE
 from xarray.conventions import encode_dataset_coordinates
 from xarray.core import indexing
 from xarray.core.common import _contains_cftime_datetimes
@@ -1088,8 +1089,9 @@ def test_roundtrip_empty_vlen_string_array(self) -> None:
                 # eg. NETCDF3 based backends do not roundtrip metadata
                 if actual["a"].dtype.metadata is not None:
                     assert check_vlen_dtype(actual["a"].dtype) is str
+            elif HAS_STRING_DTYPE:
+                assert np.issubdtype(actual["a"].dtype, np.dtypes.StringDType())
             else:
-                # zarr v3 sends back "<U1"
                 assert np.issubdtype(actual["a"].dtype, np.dtype("=U1"))
 
     @pytest.mark.parametrize(
diff --git a/xarray/tests/test_duck_array_ops.py b/xarray/tests/test_duck_array_ops.py
index 645a975a15a..a9a2d377449 100644
--- a/xarray/tests/test_duck_array_ops.py
+++ b/xarray/tests/test_duck_array_ops.py
@@ -13,6 +13,7 @@
 
 from xarray import DataArray, Dataset, concat, date_range
 from xarray.coding.times import _NS_PER_TIME_DELTA
+from xarray.compat.npcompat import HAS_STRING_DTYPE
 from xarray.core import dtypes, duck_array_ops
 from xarray.core.duck_array_ops import (
     array_notnull_equiv,
@@ -763,6 +764,43 @@ def test_isnull_with_dask():
     assert_equal(da.isnull().load(), da.load().isnull())
 
 
+@pytest.mark.skipif(not HAS_STRING_DTYPE, reason="requires StringDType to exist")
+@pytest.mark.parametrize(
+    ["input", "na_object", "expected"],
+    [
+        (
+            ["a", None, "c"],
+            None,
+            np.array([False, True, False]),
+        ),
+        (
+            ["a", "", "c"],
+            "",
+            np.array([False, True, False]),
+        ),
+        (
+            ["a", np.nan, "c"],
+            np.nan,
+            np.array([False, True, False]),
+        ),
+    ],
+)
+def test_isnull_with_different_StringDType_na_objects(input, na_object, expected):
+    dtype = np.dtypes.StringDType(na_object=na_object)
+    array = np.array(input, dtype=dtype)
+    actual = duck_array_ops.isnull(array)
+    np.testing.assert_equal(actual, expected)
+
+
+@pytest.mark.skipif(not HAS_STRING_DTYPE, reason="requires StringDType to exist")
+def test_isnull_with_default_StringDType():
+    dtype = np.dtypes.StringDType()
+    array = np.array(["a", np.nan, "c"], dtype=dtype)
+    expected = np.array([False, False, False])
+    actual = duck_array_ops.isnull(array)
+    np.testing.assert_equal(actual, expected)
+
+
 @pytest.mark.skipif(not has_dask, reason="This is for dask.")
 @pytest.mark.parametrize("axis", [0, -1, 1])
 @pytest.mark.parametrize("edge_order", [1, 2])