Repository: pydata/xarray (Python)

Issue:
Zarr store array dtype changes for empty object string
### What happened?

Saving a dataset with an empty object (string) variable to zarr changes the dtype to float64.

### What did you expect to happen?

The dtype should remain as `O`, just as it does for non-empty object variables.

### Minimal Complete Verifiable Example

```Python
import numpy as np
import xarray as xr
ds = xr.Dataset({"a": np.array([], dtype="O")})
ds["a"].dtype
# prints: dtype('O')
ds.to_zarr("a.zarr")
ds = xr.open_dataset("a.zarr")
ds["a"].dtype
# prints: dtype('float64')
```


### 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.

### Relevant log output

_No response_

### Anything else we need to know?

_No response_

### Environment

<details>

INSTALLED VERSIONS
------------------
commit: None
python: 3.8.13 (default, Mar 28 2022, 06:16:26) 
[Clang 12.0.0 ]
python-bits: 64
OS: Darwin
OS-release: 21.6.0
machine: x86_64
processor: i386
byteorder: little
LC_ALL: None
LANG: en_GB.UTF-8
LOCALE: ('en_GB', 'UTF-8')
libhdf5: None
libnetcdf: None

xarray: 2022.11.0
pandas: 1.5.0
numpy: 1.23.4
scipy: 1.9.2
netCDF4: None
pydap: None
h5netcdf: None
h5py: None
Nio: None
zarr: 2.13.3
cftime: None
nc_time_axis: None
PseudoNetCDF: None
rasterio: None
cfgrib: None
iris: None
bottleneck: None
dask: 2022.10.0
distributed: 2022.10.0
matplotlib: 3.6.1
cartopy: None
seaborn: 0.12.0
numbagg: None
fsspec: 2022.8.2
cupy: None
pint: None
sparse: None
flox: None
numpy_groupies: None
setuptools: 63.4.1
pip: 22.2.2
conda: None
pytest: 7.1.3
IPython: 8.5.0
sphinx: 4.2.0
</details>



Discussion: This behaviour stems from this part of `_infer_dtype` where empty object arrays are converted to float arrays:

https://github.com/pydata/xarray/blob/3aa75c8d00a4a2d4acf10d80f76b937cadb666b7/xarray/conventions.py#L156-L157

Is there any reason we couldn't `return strings.create_vlen_dtype(str)` instead?



Fix (patch):
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 420b6c55d56..59f910911f1 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -50,6 +50,8 @@ Bug fixes
   By `Mattia Almansi <https://github.com/malmans2>`_.
 - Don't call ``CachingFileManager.__del__`` on interpreter shutdown (:issue:`7814`, :pull:`7880`).
   By `Justus Magin <https://github.com/keewis>`_.
+- Preserve vlen dtype for empty string arrays (:issue:`7328`, :pull:`7862`).
+  By `Tom White <https://github.com/tomwhite>`_ and `Kai Mühlbauer <https://github.com/kmuehlbauer>`_.
 - Ensure dtype of reindex result matches dtype of the original DataArray (:issue:`7299`, :pull:`7917`)
   By `Anderson Banihirwe <https://github.com/andersy005>`_.
 
diff --git a/xarray/backends/netCDF4_.py b/xarray/backends/netCDF4_.py
index d3866e90de6..8a5d48c8c1e 100644
--- a/xarray/backends/netCDF4_.py
+++ b/xarray/backends/netCDF4_.py
@@ -65,10 +65,12 @@ def __init__(self, variable_name, datastore):
 
         dtype = array.dtype
         if dtype is str:
-            # use object dtype because that's the only way in numpy to
-            # represent variable length strings; it also prevents automatic
-            # string concatenation via conventions.decode_cf_variable
-            dtype = np.dtype("O")
+            # use object dtype (with additional vlen string metadata) because that's
+            # the only way in numpy to represent variable length strings and to
+            # check vlen string dtype in further steps
+            # it also prevents automatic string concatenation via
+            # conventions.decode_cf_variable
+            dtype = coding.strings.create_vlen_dtype(str)
         self.dtype = dtype
 
     def __setitem__(self, key, value):
diff --git a/xarray/backends/zarr.py b/xarray/backends/zarr.py
index a4012a8a733..5c3d5781e35 100644
--- a/xarray/backends/zarr.py
+++ b/xarray/backends/zarr.py
@@ -70,7 +70,14 @@ def __init__(self, variable_name, datastore):
         array = self.get_array()
         self.shape = array.shape
 
-        dtype = array.dtype
+        # preserve vlen string object dtype (GH 7328)
+        if array.filters is not None and any(
+            [filt.codec_id == "vlen-utf8" for filt in array.filters]
+        ):
+            dtype = coding.strings.create_vlen_dtype(str)
+        else:
+            dtype = array.dtype
+
         self.dtype = dtype
 
     def get_array(self):
diff --git a/xarray/coding/strings.py b/xarray/coding/strings.py
index ffe1b1a8d50..d0bfb1a7a63 100644
--- a/xarray/coding/strings.py
+++ b/xarray/coding/strings.py
@@ -29,7 +29,8 @@ def check_vlen_dtype(dtype):
     if dtype.kind != "O" or dtype.metadata is None:
         return None
     else:
-        return dtype.metadata.get("element_type")
+        # check xarray (element_type) as well as h5py (vlen)
+        return dtype.metadata.get("element_type", dtype.metadata.get("vlen"))
 
 
 def is_unicode_dtype(dtype):
diff --git a/xarray/conventions.py b/xarray/conventions.py
index 1506efc31e8..053863ace2a 100644
--- a/xarray/conventions.py
+++ b/xarray/conventions.py
@@ -108,6 +108,10 @@ def ensure_dtype_not_object(var: Variable, name: T_Name = None) -> Variable:
     if var.dtype.kind == "O":
         dims, data, attrs, encoding = _var_as_tuple(var)
 
+        # leave vlen dtypes unchanged
+        if strings.check_vlen_dtype(data.dtype) is not None:
+            return var
+
         if is_duck_dask_array(data):
             warnings.warn(
                 "variable {} has data in the form of a dask array with "