# FutureWarning: decode_timedelta will default to False rather than None

### What happened?

I followed some tutorial/generated code to try out accessing weather forecast data. It does seem to work but prints a long warning message in the middle of my program output:

C:\Users\...\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache\local-packages\Python313\site-packages\cfgrib\xarray_plugin.py:131: FutureWarning: In a future version of xarray decode_timedelta will default to False rather than None. To silence this warning, set decode_timedelta to True, False, or a 'CFTimedeltaCoder' instance.

### What did you expect to happen?

Nothing?

### Minimal Complete Verifiable Example

```Python
import os
import bz2
import requests
import xarray as xr
import numpy as np
import pandas as pd
from scipy.interpolate import griddata

# Parameter
run_date = "20250601"
run_hour = "09"
forecast_hour = "001"
base_url = f"https://opendata.dwd.de/weather/nwp/icon-d2/grib/{run_hour}/t_2m/"
filename = f"icon-d2_germany_regular-lat-lon_single-level_{run_date}{run_hour}_{forecast_hour}_2d_t_2m.grib2.bz2"
grib_bz2_url = base_url + filename
grib_outfile = "t2m.grib2"

# Koordinaten der Orte (Breite, Länge)
locations = {
    "Berlin": (52.52, 13.405),
    "München": (48.137, 11.575),
}

# Download und Dekomprimierung
def download_grib_bz2(url, outfile):
    print(f"Lade herunter: {url}")
    r = requests.get(url)
    if r.status_code != 200:
        raise Exception(f"Fehler beim Download: {r.status_code}")
    decompressed = bz2.decompress(r.content)
    with open(outfile, "wb") as f:
        f.write(decompressed)
    print(f"Gespeichert als: {outfile}")

# Extrahiere Temperatur für Koordinaten
def extract_temperature(gribfile, coords):
    ds = xr.open_dataset(gribfile, engine="cfgrib")
    lat = ds.latitude.values
    lon = ds.longitude.values
    temp = ds.t2m.values - 273.15  # Kelvin -> Celsius

    lon_grid, lat_grid = np.meshgrid(lon, lat)
    flat_points = np.column_stack((lat_grid.ravel(), lon_grid.ravel()))
    flat_values = temp.ravel()

    results = {}
    for city, (la, lo) in coords.items():
        val = griddata(flat_points, flat_values, (la, lo), method='linear')
        results[city] = round(float(val), 2)
    return results

# Ausführen
download_grib_bz2(grib_bz2_url, grib_outfile)
temperatures = extract_temperature(grib_outfile, locations)
df = pd.DataFrame.from_dict(temperatures, orient="index", columns=["Temp (°C)"])
print(df)
```

### 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.
- [ ] 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?

I have no idea whether the code is minimal. I don't know where the issue comes from so I cannot trim it down.

It should run as-is. Maybe the PIP packages xarray, requests, scipy, cfgrib need to be installed.

Python version is 3.13.3 on Windows 11. Nothing newer is available now.

### Environment

<details>
INSTALLED VERSIONS
------------------
commit: None
python: 3.13.3 (tags/v3.13.3:6280bb5, Apr  8 2025, 14:47:33) [MSC v.1943 64 bit (AMD64)]
python-bits: 64
OS: Windows
OS-release: 11
machine: AMD64
processor: AMD64 Family 25 Model 97 Stepping 2, AuthenticAMD
byteorder: little
LC_ALL: None
LANG: None
LOCALE: ('de_DE', 'cp1252')
libhdf5: None
libnetcdf: None

xarray: 2025.4.0
pandas: 2.2.3
numpy: 2.2.6
scipy: 1.15.3
netCDF4: None
pydap: None
h5netcdf: None
h5py: None
zarr: None
cftime: 1.6.4.post1
nc_time_axis: None
iris: None
bottleneck: None
dask: None
distributed: None
matplotlib: None
cartopy: None
seaborn: None
numbagg: None
fsspec: None
cupy: None
pint: None
sparse: None
flox: None
numpy_groupies: None
setuptools: None
pip: 25.1.1
conda: None
pytest: None
mypy: None
IPython: None
sphinx: None
</details>

diff --git a/xarray/tests/test_coding_times.py b/xarray/tests/test_coding_times.py
index 1bb533dc7d8..15e38233c4b 100644
--- a/xarray/tests/test_coding_times.py
+++ b/xarray/tests/test_coding_times.py
@@ -1825,67 +1825,49 @@ def test_encode_cf_timedelta_small_dtype_missing_value(use_dask) -> None:

 _DECODE_TIMEDELTA_VIA_UNITS_TESTS = {
-    "default": (True, None, np.dtype("timedelta64[ns]"), True),
-    "decode_timedelta=True": (True, True, np.dtype("timedelta64[ns]"), False),
-    "decode_timedelta=False": (True, False, np.dtype("int64"), False),
-    "inherit-time_unit-from-decode_times": (
-        CFDatetimeCoder(time_unit="s"),
-        None,
-        np.dtype("timedelta64[s]"),
-        True,
-    ),
+    "default": (True, None, np.dtype("int64")),
+    "decode_timedelta=True": (True, True, np.dtype("timedelta64[ns]")),
+    "decode_timedelta=False": (True, False, np.dtype("int64")),
     "set-time_unit-via-CFTimedeltaCoder-decode_times=True": (
         True,
-        CFTimedeltaCoder(time_unit="s"),
+        CFTimedeltaCoder(decode_via_units=True, time_unit="s"),
         np.dtype("timedelta64[s]"),
-        False,
     ),
     "set-time_unit-via-CFTimedeltaCoder-decode_times=False": (
         False,
-        CFTimedeltaCoder(time_unit="s"),
+        CFTimedeltaCoder(decode_via_units=True, time_unit="s"),
         np.dtype("timedelta64[s]"),
-        False,
     ),
     "override-time_unit-from-decode_times": (
         CFDatetimeCoder(time_unit="ns"),
-        CFTimedeltaCoder(time_unit="s"),
+        CFTimedeltaCoder(decode_via_units=True, time_unit="s"),
         np.dtype("timedelta64[s]"),
-        False,
     ),
 }

 @pytest.mark.parametrize(
-    ("decode_times", "decode_timedelta", "expected_dtype", "warns"),
+    ("decode_times", "decode_timedelta", "expected_dtype"),
     list(_DECODE_TIMEDELTA_VIA_UNITS_TESTS.values()),
     ids=list(_DECODE_TIMEDELTA_VIA_UNITS_TESTS.keys()),
 )
 def test_decode_timedelta_via_units(
-    decode_times, decode_timedelta, expected_dtype, warns
+    decode_times, decode_timedelta, expected_dtype
 ) -> None:
     timedeltas = pd.timedelta_range(0, freq="D", periods=3)
     attrs = {"units": "days"}
     var = Variable(["time"], timedeltas, encoding=attrs)
     encoded = Variable(["time"], np.array([0, 1, 2]), attrs=attrs)
-    if warns:
-        with pytest.warns(
-            FutureWarning,
-            match="xarray will not decode the variable 'foo' into a timedelta64 dtype",
-        ):
-            decoded = conventions.decode_cf_variable(
-                "foo",
-                encoded,
-                decode_times=decode_times,
-                decode_timedelta=decode_timedelta,
-            )
+    decoded = conventions.decode_cf_variable(
+        "foo", encoded, decode_times=decode_times, decode_timedelta=decode_timedelta
+    )
+    if decode_timedelta is True or (
+        isinstance(decode_timedelta, CFTimedeltaCoder)
+        and decode_timedelta.decode_via_units
+    ):
+        assert_equal(var, decoded)
     else:
-        decoded = conventions.decode_cf_variable(
-            "foo", encoded, decode_times=decode_times, decode_timedelta=decode_timedelta
-        )
-    if decode_timedelta is False:
         assert_equal(encoded, decoded)
-    else:
-        assert_equal(var, decoded)
     assert decoded.dtype == expected_dtype

@@ -1954,7 +1936,7 @@ def test_decode_timedelta_via_dtype(
 @pytest.mark.parametrize("dtype", [np.uint64, np.int64, np.float64])
 def test_decode_timedelta_dtypes(dtype) -> None:
     encoded = Variable(["time"], np.arange(10), {"units": "seconds"})
-    coder = CFTimedeltaCoder(time_unit="s")
+    coder = CFTimedeltaCoder(decode_via_units=True, time_unit="s")
     decoded = coder.decode(encoded)
     assert decoded.dtype.kind == "m"
     assert_equal(coder.encode(decoded), encoded)
@@ -1963,8 +1945,9 @@ def test_decode_timedelta_dtypes(dtype) -> None:
 def test_lazy_decode_timedelta_unexpected_dtype() -> None:
     attrs = {"units": "seconds"}
     encoded = Variable(["time"], [0, 0.5, 1], attrs=attrs)
+    decode_timedelta = CFTimedeltaCoder(decode_via_units=True, time_unit="s")
     decoded = conventions.decode_cf_variable(
-        "foo", encoded, decode_timedelta=CFTimedeltaCoder(time_unit="s")
+        "foo", encoded, decode_timedelta=decode_timedelta
     )

     expected_dtype_upon_lazy_decoding = np.dtype("timedelta64[s]")
@@ -1978,8 +1961,9 @@ def test_lazy_decode_timedelta_unexpected_dtype() -> None:
 def test_lazy_decode_timedelta_error() -> None:
     attrs = {"units": "seconds"}
     encoded = Variable(["time"], [0, np.iinfo(np.int64).max, 1], attrs=attrs)
+    decode_timedelta = CFTimedeltaCoder(decode_via_units=True, time_unit="ms")
     decoded = conventions.decode_cf_variable(
-        "foo", encoded, decode_timedelta=CFTimedeltaCoder(time_unit="ms")
+        "foo", encoded, decode_timedelta=decode_timedelta
     )
     with pytest.raises(OutOfBoundsTimedelta, match="overflow"):
         decoded.load()
diff --git a/xarray/tests/test_conventions.py b/xarray/tests/test_conventions.py
index 461bd727e64..38b835fd3d5 100644
--- a/xarray/tests/test_conventions.py
+++ b/xarray/tests/test_conventions.py
@@ -553,7 +553,9 @@ def test_decode_cf_time_kwargs(self, time_unit) -> None:
         dsc = conventions.decode_cf(
             ds,
             decode_times=CFDatetimeCoder(time_unit=time_unit),
-            decode_timedelta=CFTimedeltaCoder(time_unit=time_unit),
+            decode_timedelta=CFTimedeltaCoder(
+                decode_via_units=True, time_unit=time_unit
+            ),
         )
         assert dsc.timedelta.dtype == np.dtype(f"m8[{time_unit}]")
         assert dsc.time.dtype == np.dtype(f"M8[{time_unit}]")
@@ -679,15 +681,3 @@ def test_encode_cf_variable_with_vlen_dtype() -> None:
     encoded_v = conventions.encode_cf_variable(v)
     assert encoded_v.data.dtype.kind == "O"
     assert coding.strings.check_vlen_dtype(encoded_v.data.dtype) is str
-
-
-def test_decode_cf_variables_decode_timedelta_warning() -> None:
-    v = Variable(["time"], [1, 2], attrs={"units": "seconds"})
-    variables = {"a": v}
-
-    with warnings.catch_warnings():
-        warnings.filterwarnings("error", "decode_timedelta", FutureWarning)
-        conventions.decode_cf_variables(variables, {}, decode_timedelta=True)
-
-    with pytest.warns(FutureWarning, match="decode_timedelta"):
-        conventions.decode_cf_variables(variables, {})

diff --git a/xarray/coding/times.py b/xarray/coding/times.py
index d45e8e4dc9f..b60acbcf407 100644
--- a/xarray/coding/times.py
+++ b/xarray/coding/times.py
@@ -1492,13 +1492,12 @@ class CFTimedeltaCoder(VariableCoder):
     def __init__(
         self,
         time_unit: PDDatetimeUnitOptions | None = None,
-        decode_via_units: bool = True,
+        decode_via_units: bool = False,
         decode_via_dtype: bool = True,
     ) -> None:
         self.time_unit = time_unit
         self.decode_via_units = decode_via_units
         self.decode_via_dtype = decode_via_dtype
-        self._emit_decode_timedelta_future_warning = False

     def encode(self, variable: Variable, name: T_Name = None) -> Variable:
         if np.issubdtype(variable.dtype, np.timedelta64):
@@ -1540,23 +1539,6 @@ def decode(self, variable: Variable, name: T_Name = None) -> Variable:
                 else:
                     time_unit = self.time_unit
             else:
-                if self._emit_decode_timedelta_future_warning:
-                    var_string = f"the variable {name!r}" if name else ""
-                    emit_user_level_warning(
-                        "In a future version, xarray will not decode "
-                        f"{var_string} into a timedelta64 dtype based on the "
-                        "presence of a timedelta-like 'units' attribute by "
-                        "default. Instead it will rely on the presence of a "
-                        "timedelta64 'dtype' attribute, which is now xarray's "
-                        "default way of encoding timedelta64 values.\n"
-                        "To continue decoding into a timedelta64 dtype, either "
-                        "set `decode_timedelta=True` when opening this "
-                        "dataset, or add the attribute "
-                        "`dtype='timedelta64[ns]'` to this variable on disk.\n"
-                        "To opt-in to future behavior, set "
-                        "`decode_timedelta=False`.",
-                        FutureWarning,
-                    )
                 if self.time_unit is None:
                     time_unit = "ns"
                 else:
diff --git a/xarray/conventions.py b/xarray/conventions.py
index 8f804923f30..d3ee05e5da1 100644
--- a/xarray/conventions.py
+++ b/xarray/conventions.py
@@ -173,12 +173,13 @@ def decode_cf_variable(

     original_dtype = var.dtype

-    decode_timedelta_was_none = decode_timedelta is None
     if decode_timedelta is None:
         if isinstance(decode_times, CFDatetimeCoder):
             decode_timedelta = CFTimedeltaCoder(time_unit=decode_times.time_unit)
+        elif decode_times:
+            decode_timedelta = CFTimedeltaCoder()
         else:
-            decode_timedelta = bool(decode_times)
+            decode_timedelta = False

     if concat_characters:
         if stack_char_dim:
@@ -208,9 +209,6 @@ def decode_cf_variable(
             decode_timedelta = CFTimedeltaCoder(
                 decode_via_units=decode_timedelta, decode_via_dtype=decode_timedelta
             )
-        decode_timedelta._emit_decode_timedelta_future_warning = (
-            decode_timedelta_was_none
-        )
         var = decode_timedelta.decode(var, name=name)
     if decode_times:
         # remove checks after end of deprecation cycle


# Reading data after saving data from masked arrays results in different numbers

I hit an issue that can go silent for many users.

I am masking my data using [`dask.array.ma.masked_invalid`](https://docs.dask.org/en/stable/generated/dask.array.ma.masked_invalid.html) (my dataset is much larger than my memory). When saving the results and loading them again, the data is changed. In particular, `0.` is assigned to those elements that where masked instead of `np.nan` or `fill_value`, which is `1e+20`.

Below an example that illustrates the issue:
```python
In [1]: import dask.array as da
   ...: import numpy as np
   ...: import xarray as xr

In [2]: ds = xr.DataArray(
   ...:     da.stack(
   ...:         [da.from_array(np.array([[np.nan, np.nan], [np.nan, 2]])) for _ in range(
   ...: 10)],
   ...:         axis=0
   ...:     ).astype('float32'),
   ...:     dims=('time', 'lat', 'lon')
   ...: ).to_dataset(name='mydata')

In [3]: # Mask my data

In [4]: ds = xr.apply_ufunc(da.ma.masked_invalid, ds, dask='allowed')

In [5]: ds.mean('time').compute()
Out[5]:
<xarray.Dataset> Size: 16B
Dimensions:  (lat: 2, lon: 2)
Dimensions without coordinates: lat, lon
Data variables:
    mydata   (lat, lon) float32 16B nan nan nan 2.0

In [6]: # Write to file

In [7]: ds.mean('time').to_netcdf('foo.nc')

In [8]: # Read foo.nc

In [9]: foo = xr.open_dataset('foo.nc')

In [10]: foo.compute()
Out[10]:
<xarray.Dataset> Size: 16B
Dimensions:  (lat: 2, lon: 2)
Dimensions without coordinates: lat, lon
Data variables:
    mydata   (lat, lon) float32 16B 0.0 0.0 0.0 2.0
```

I expected `mydata` to be either `[np.nan, np.nan, np.nan, 2.0]`, `numpy.MaskedArray`, or `[1e+20, 1e+20, 1e+20, 2.0]`, since:
```python
In [11]: ds.mean('time')['mydata'].data.compute()
Out[11]:
masked_array(
  data=[[--, --],
        [--, 2.0]],
  mask=[[ True,  True],
        [ True, False]],
  fill_value=1e+20,
  dtype=float32)
```
instead of `[0.0, 0.0, 0.0, 2.0]`. No warning is raised, and I do not get why the fill values are replaced by `0.`

A way to address this for my data (but might not be true for all cases) is to use [`xarray.where`](https://docs.xarray.dev/en/stable/generated/xarray.where.html) before writing to file as
```python
In [12]: xr.where(ds.mean('time'), ds.mean('time'), np.nan).to_netcdf('foo.nc')

In [13]: foo = xr.open_dataset('foo.nc')

In [14]: foo.compute()
Out[14]:
<xarray.Dataset> Size: 16B
Dimensions:  (lat: 2, lon: 2)
Dimensions without coordinates: lat, lon
Data variables:
    mydata   (lat, lon) float32 16B nan nan nan 2.0
```
or mask the data in some other way, e.g. using xarray.where in the beginning instead of xarray.apply_ufunc.

  Output of xr.show_versions()

INSTALLED VERSIONS
------------------
commit: None
python: 3.12.5 | packaged by conda-forge | (main, Aug  8 2024, 18:36:51) [GCC 12.4.0]
python-bits: 64
OS: Linux
OS-release: 5.15.153.1-microsoft-standard-WSL2
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: None
LANG: C.UTF-8
LOCALE: ('C', 'UTF-8')
libhdf5: 1.14.3
libnetcdf: 4.9.2

xarray: 2024.9.0
pandas: 2.2.2
numpy: 2.1.1
scipy: None
netCDF4: 1.7.1
pydap: None
h5netcdf: None
h5py: None
zarr: None
cftime: 1.6.4
nc_time_axis: None
iris: None
bottleneck: None
dask: 2024.9.0
distributed: 2024.9.0
matplotlib: None
cartopy: None
seaborn: None
numbagg: None
fsspec: 2024.9.0
cupy: None
pint: None
sparse: None
flox: None
numpy_groupies: None
setuptools: 73.0.1
pip: 24.2
conda: None
pytest: None
mypy: None
IPython: 8.27.0
sphinx: None

diff --git a/xarray/tests/test_backends.py b/xarray/tests/test_backends.py
index f7d2b516a05..ff5470094ec 100644
--- a/xarray/tests/test_backends.py
+++ b/xarray/tests/test_backends.py
@@ -85,6 +85,7 @@
     mock,
     network,
     parametrize_zarr_format,
+    raise_if_dask_computes,
     requires_cftime,
     requires_dask,
     requires_fsspec,
@@ -2231,6 +2232,28 @@ def test_create_default_indexes(self, tmp_path, create_default_indexes) -> None:
             else:
                 assert len(loaded_ds.xindexes) == 0

+    @requires_dask
+    def test_encoding_masked_arrays(self, tmp_path) -> None:
+        store_path = tmp_path / "tmp.nc"
+
+        with raise_if_dask_computes():
+            ds = xr.DataArray(
+                dask.array.from_array(
+                    np.ma.masked_array(
+                        np.array([[np.nan, np.nan], [np.nan, 2]]),
+                        np.array([[True, True], [True, False]]),
+                    )
+                ).astype("float32"),
+                dims=("x", "y"),
+            ).to_dataset(name="mydata")
+
+        expected = ds.mean("x")
+        expected.to_netcdf(
+            store_path, encoding=dict(mydata=dict(_FillValue=np.float32(1e20)))
+        )
+        with open_dataset(store_path, engine=self.engine) as actual:
+            assert_identical(expected.compute(), actual.compute())
+

 @requires_netCDF4
 class TestNetCDF4Data(NetCDF4Base):
diff --git a/xarray/tests/test_variable.py b/xarray/tests/test_variable.py
index 44a044275e2..1ef6adc9caf 100644
--- a/xarray/tests/test_variable.py
+++ b/xarray/tests/test_variable.py
@@ -2757,7 +2757,7 @@ def test_masked_array(self):
         expected = np.arange(5)
         actual: Any = as_compatible_data(original)
         assert_array_equal(expected, actual)
-        assert np.dtype(int) == actual.dtype
+        assert np.dtype(float) == actual.dtype

         original1: Any = np.ma.MaskedArray(np.arange(5), mask=4 * [False] + [True])
         expected1: Any = np.arange(5.0)

diff --git a/xarray/core/duck_array_ops.py b/xarray/core/duck_array_ops.py
index 85ef75f352c..e10ab5a3558 100644
--- a/xarray/core/duck_array_ops.py
+++ b/xarray/core/duck_array_ops.py
@@ -127,6 +127,10 @@ def fail_on_dask_array_input(values, msg=None, func_name=None):
     "masked_invalid", eager_module=np.ma, dask_module="dask.array.ma"
 )

+getmaskarray = _dask_or_eager_func(
+    "getmaskarray", eager_module=np.ma, dask_module="dask.array.ma"
+)
+

 def sliding_window_view(array, window_shape, axis=None, **kwargs):
     # TODO: some libraries (e.g. jax) don't have this, implement an alternative?
diff --git a/xarray/core/variable.py b/xarray/core/variable.py
index 7478d48bb23..ab8b4be775b 100644
--- a/xarray/core/variable.py
+++ b/xarray/core/variable.py
@@ -50,6 +50,7 @@
 from xarray.namedarray.core import NamedArray, _raise_if_any_duplicate_dimensions
 from xarray.namedarray.parallelcompat import get_chunked_array_type
 from xarray.namedarray.pycompat import (
+    array_type,
     async_to_duck_array,
     integer_types,
     is_0d_dask_array,
@@ -292,13 +293,12 @@ def convert_non_numpy_type(data):
         else:
             data = pandas_data

-    if isinstance(data, np.ma.MaskedArray):
-        mask = np.ma.getmaskarray(data)
-        if mask.any():
-            _dtype, fill_value = dtypes.maybe_promote(data.dtype)
-            data = duck_array_ops.where_method(data, ~mask, fill_value)
-        else:
-            data = np.asarray(data)
+    if isinstance(data, np.ma.MaskedArray) or (
+        isinstance(data, array_type("dask"))
+        and isinstance(getattr(data, "_meta", None), np.ma.MaskedArray)
+    ):
+        mask = duck_array_ops.getmaskarray(data)
+        data = duck_array_ops.where_method(data, ~mask)

     if isinstance(data, np.matrix):
         data = np.asarray(data)


# Slicing a MultiIndex level is broken

### What happened?

```python
import pandas as pd
import xarray as xr

midx = pd.MultiIndex.from_product([["a", "b"], [1, 2]], names=("foo", "bar"))
midx_coords = xr.Coordinates.from_pandas_multiindex(midx, dim="x")

ds = xr.Dataset(coords=midx_coords)
ds.sel(bar=slice(1, 2))
```
```
<xarray.Dataset> Size: 40B
Dimensions:  (foo: 4)
Coordinates:
  * foo      (foo) object 32B 'a' 'a' 'b' 'b'
    bar      object 8B slice(1, 2, None)
Data variables:
    *empty*
```

Possibly this cannot work, but we should raise an error in that case

diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
index 855f758e5fe..f00c5e4aed7 100644
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -2246,6 +2246,12 @@ def test_sel(

         assert_identical(mdata.sel(x={"one": "a", "two": 1}), mdata.sel(one="a", two=1))

+        # GH10534: slicing on multi-index levels should raise
+        with pytest.raises(ValueError, match="slice-based selection on multi-index"):
+            mdata.sel(one=slice("a", "b"))
+        with pytest.raises(ValueError, match="slice-based selection on multi-index"):
+            mdata.sel(two=slice(1, 2))
+
     def test_broadcast_like(self) -> None:
         original1 = DataArray(
             np.random.randn(5), [("x", range(5))], name="a"

diff --git a/xarray/core/indexes.py b/xarray/core/indexes.py
index 5c44a799229..664afc83590 100644
--- a/xarray/core/indexes.py
+++ b/xarray/core/indexes.py
@@ -1308,6 +1308,16 @@ def sel(self, labels, method=None, tolerance=None) -> IndexSelResult:

             has_slice = any(isinstance(v, slice) for v in label_values.values())

+            if has_slice:
+                slice_levels = [
+                    k for k, v in label_values.items() if isinstance(v, slice)
+                ]
+                raise ValueError(
+                    f"slice-based selection on multi-index level(s) {slice_levels} "
+                    f"is not supported. Use scalar values for multi-index level "
+                    f"selection instead, e.g., ``.sel({slice_levels[0]}=value)``."
+                )
+
             if len(label_values) == self.index.nlevels and not has_slice:
                 indexer = self.index.get_loc(
                     tuple(label_values[k] for k in self.index.names)


# `open_dataset` should fail early, before reaching `guess_engine` when file doesn't exist

### What happened?

I ran a code in a script from long ago that uses `xr.open_dataset`, and I gave the script a path to a file that doesn't exist, and got this error:

```
ValueError: did not find a match in any of xarray's currently installed IO backends ['h5netcdf', 'scipy', 'zarr']. Consider explicitly selecting one of the installed engines via the ``engine`` parameter, or installing additional IO dependencies, see:
https://docs.xarray.dev/en/stable/getting-started-guide/installing.html
https://docs.xarray.dev/en/stable/user-guide/io.html
```

This made me think that maybe `xarray` got updated and the engine guessing algorithm got different. But apparently I was wrong, and the `open_dataset` file simply don't exist.

### What did you expect to happen?

`open_dataset` to fail earlier saying there is no file in the given path, and print the path too in the error message.

### Minimal Complete Verifiable Example

```Python
import xarray as xr
xr.show_versions()

xr.open_dataset("missing.h5")
```

### Steps to reproduce

I cannot use `uv` to show demonstrate the issue (I use NixOS), but the issue should be simple enough for you to forgive me hopefully. It might be that installing an HDF5 backend is required, but I'm not sure.

### 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.
- [ ] Recent environment — the issue occurs with the latest version of xarray and its dependencies.

### Relevant log output

```Python
ValueError: did not find a match in any of xarray's currently installed IO backends ['h5netcdf', 'scipy', 'zarr']. Consider explicitly selecting one of the installed engines via the ``engine`` parameter, or installing additional IO dependencies, see:
https://docs.xarray.dev/en/stable/getting-started-guide/installing.html
https://docs.xarray.dev/en/stable/user-guide/io.html
```

### Anything else we need to know?

_No response_

### Environment

<details>

INSTALLED VERSIONS
------------------
commit: None
python: 3.13.8 (main, Oct  7 2025, 12:01:51) [GCC 14.3.0]
python-bits: 64
OS: Linux
OS-release: 6.12.56
machine: x86_64
processor:
byteorder: little
LC_ALL: None
LANG: en_IL
LOCALE: ('en_IL', 'ISO8859-1')
libhdf5: 1.14.6
libnetcdf: None

xarray: 2025.7.1
pandas: 2.3.1
numpy: 2.3.3
scipy: 1.16.2
netCDF4: None
pydap: None
h5netcdf: 1.6.4
h5py: 3.14.0
zarr: 3.1.1
cftime: None
nc_time_axis: None
iris: None
bottleneck: None
dask: None
distributed: None
matplotlib: 3.10.5
cartopy: None
seaborn: None
numbagg: None
fsspec: None
cupy: None
pint: 0.25.0.dev0
sparse: None
flox: None
numpy_groupies: None
setuptools: None
pip: None
conda: None
pytest: None
mypy: 1.17.1
IPython: None
sphinx: None

</details>

diff --git a/xarray/tests/test_plugins.py b/xarray/tests/test_plugins.py
index e20f665c9ff..5bb17c06eb2 100644
--- a/xarray/tests/test_plugins.py
+++ b/xarray/tests/test_plugins.py
@@ -188,24 +188,66 @@ def test_build_engines_sorted() -> None:
     "xarray.backends.plugins.list_engines",
     mock.MagicMock(return_value={"dummy": DummyBackendEntrypointArgs()}),
 )
-def test_no_matching_engine_found() -> None:
-    with pytest.raises(ValueError, match=r"did not find a match in any"):
+def test_no_matching_engine_found(tmp_path) -> None:
+    # Non-existent local file raises FileNotFoundError
+    with pytest.raises(FileNotFoundError, match=r"No such file"):
         plugins.guess_engine("not-valid")

+    # Existing file with unrecognized extension raises ValueError
+    existing_file = tmp_path / "test.unknown"
+    existing_file.write_bytes(b"")
+    with pytest.raises(ValueError, match=r"did not find a match in any"):
+        plugins.guess_engine(str(existing_file))
+
+    # Existing file with recognized magic number raises ValueError
+    nc_file = tmp_path / "foo.nc"
+    nc_file.write_bytes(b"CDF\x01\x00\x00\x00\x00")
     with pytest.raises(ValueError, match=r"found the following matches with the input"):
-        plugins.guess_engine("foo.nc")
+        plugins.guess_engine(str(nc_file))

 @mock.patch(
     "xarray.backends.plugins.list_engines",
     mock.MagicMock(return_value={}),
 )
-def test_engines_not_installed() -> None:
-    with pytest.raises(ValueError, match=r"xarray is unable to open"):
+def test_engines_not_installed(tmp_path) -> None:
+    # Non-existent local file raises FileNotFoundError
+    with pytest.raises(FileNotFoundError, match=r"No such file"):
         plugins.guess_engine("not-valid")

+    # Existing file with no matching engine raises ValueError
+    existing_file = tmp_path / "test.unknown"
+    existing_file.write_bytes(b"")
+    with pytest.raises(ValueError, match=r"xarray is unable to open"):
+        plugins.guess_engine(str(existing_file))
+
+    # Existing file with recognized magic number raises ValueError
+    nc_file = tmp_path / "foo.nc"
+    nc_file.write_bytes(b"CDF\x01\x00\x00\x00\x00")
     with pytest.raises(ValueError, match=r"found the following matches with the input"):
-        plugins.guess_engine("foo.nc")
+        plugins.guess_engine(str(nc_file))
+
+
+@mock.patch(
+    "xarray.backends.plugins.list_engines",
+    mock.MagicMock(return_value={"dummy": DummyBackendEntrypointArgs()}),
+)
+def test_guess_engine_file_not_found() -> None:
+    # Non-existent local file path (string)
+    with pytest.raises(
+        FileNotFoundError, match=r"No such file: '/nonexistent/path.h5'"
+    ):
+        plugins.guess_engine("/nonexistent/path.h5")
+
+    # Non-existent local file path (PathLike)
+    from pathlib import Path
+
+    with pytest.raises(FileNotFoundError, match=r"No such file"):
+        plugins.guess_engine(Path("/nonexistent/path.h5"))
+
+    # Remote URIs should not raise FileNotFoundError (raises ValueError instead)
+    with pytest.raises(ValueError):
+        plugins.guess_engine("https://example.com/missing.h5")

 @pytest.mark.parametrize("engine", common.BACKEND_ENTRYPOINTS.keys())

diff --git a/xarray/backends/plugins.py b/xarray/backends/plugins.py
index 6e1784af5b6..db79dbae7ff 100644
--- a/xarray/backends/plugins.py
+++ b/xarray/backends/plugins.py
@@ -3,6 +3,7 @@
 import functools
 import inspect
 import itertools
+import os
 import warnings
 from collections.abc import Callable
 from importlib.metadata import entry_points
@@ -10,10 +11,9 @@

 from xarray.backends.common import BACKEND_ENTRYPOINTS, BackendEntrypoint
 from xarray.core.options import OPTIONS
-from xarray.core.utils import module_available
+from xarray.core.utils import is_remote_uri, module_available

 if TYPE_CHECKING:
-    import os
     from importlib.metadata import EntryPoint, EntryPoints

     from xarray.backends.common import AbstractDataStore
@@ -209,6 +209,11 @@ def guess_engine(
             "https://docs.xarray.dev/en/stable/getting-started-guide/installing.html"
         )

+    if isinstance(store_spec, str | os.PathLike):
+        store_spec_str = str(store_spec)
+        if not is_remote_uri(store_spec_str) and not os.path.exists(store_spec_str):
+            raise FileNotFoundError(f"No such file: '{store_spec_str}'")
+
     raise ValueError(error_msg)


# Nightly Hypothesis tests failed

[Workflow Run URL](https://github.com/pydata/xarray/actions/runs/21846931586)
<details><summary>Python 3.12 Test Summary</summary>

```
properties/test_index_manipulation.py::DatasetTest::runTest: ExceptionGroup: Hypothesis found 2 distinct failures. (2 sub-exceptions)
```

</details>

diff --git a/properties/test_index_manipulation.py b/properties/test_index_manipulation.py
index 6df5b1370e3..c7e1a1bbd7b 100644
--- a/properties/test_index_manipulation.py
+++ b/properties/test_index_manipulation.py
@@ -269,6 +269,13 @@ def assert_invariants(self):
 DatasetTest = DatasetStateMachine.TestCase

+@pytest.mark.skip(reason="failure detected by hypothesis")
+def test_unstack_string():
+    ds = xr.Dataset()
+    ds["0"] = np.array(["", "0", "\x000"], dtype="<U2")
+    ds.stack({"1": ["0"]}).unstack()
+
+
 @pytest.mark.skip(reason="failure detected by hypothesis")
 def test_unstack_object():
     ds = xr.Dataset()

diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
index a6de7e22771..054668a0317 100644
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -5205,7 +5205,7 @@ def _stack_once(
                 vdims = list(var.dims) + add_dims
                 shape = [self.sizes[d] for d in vdims]
                 exp_var = var.set_dims(vdims, shape)
-                stacked_var = exp_var.stack(**{new_dim: dims})
+                stacked_var = exp_var.stack({new_dim: dims})
                 new_variables[name] = stacked_var
                 stacked_var_names.append(name)
             else:
diff --git a/xarray/core/indexes.py b/xarray/core/indexes.py
index f7eff589ca2..5c44a799229 100644
--- a/xarray/core/indexes.py
+++ b/xarray/core/indexes.py
@@ -536,7 +536,13 @@ def safe_cast_to_index(array: Any) -> pd.Index:
                 )
                 kwargs["dtype"] = "float64"

-        index = pd.Index(to_numpy(array), **kwargs)
+        values = to_numpy(array)
+        try:
+            index = pd.Index(values, **kwargs)
+        except UnicodeEncodeError:
+            # coerce to object if pandas fails to coerce to string
+            kwargs["dtype"] = "object"
+            index = pd.Index(values, **kwargs)

     return _maybe_cast_to_cftimeindex(index)


# support 1-dimensional coordinates in `NDPointIndex`

### What is your issue?

`NDPointIndex` supports indexing along multiple indexed coordinates at the same time, but only for coordinates that have 2 or more dimensions (usually curvilinear grids, like the one used by the `ROMS_example` tutorial dataset).

This excludes indexing scattered points or trajectories.

For example, what I'd like to index is something like this:
```python
import xarray as xr
import numpy as np

ds = xr.Dataset(
    coords={
        "x": ("points", np.linspace(0, 10, 20)),
        "y": ("points", np.linspace(15, 8, 20)),
    }
)
```

cc @dcherian, @benbovy, @quentinf00

diff --git a/xarray/tests/test_nd_point_index.py b/xarray/tests/test_nd_point_index.py
index 7c2cb8302c6..823d1f21a95 100644
--- a/xarray/tests/test_nd_point_index.py
+++ b/xarray/tests/test_nd_point_index.py
@@ -39,9 +39,6 @@ def test_tree_index_init_errors() -> None:
     xx, yy = np.meshgrid([1.0, 2.0], [3.0, 4.0])
     ds = xr.Dataset(coords={"xx": (("y", "x"), xx), "yy": (("y", "x"), yy)})

-    with pytest.raises(ValueError, match="number of variables"):
-        ds.set_xindex("xx", NDPointIndex)
-
     ds2 = ds.assign_coords(yy=(("u", "v"), [[3.0, 3.0], [4.0, 4.0]]))

     with pytest.raises(ValueError, match="same dimensions"):
@@ -193,3 +190,34 @@ def test_tree_index_rename() -> None:
         method="nearest",
     )
     assert_identical(actual, expected)
+
+
+@requires_scipy
+def test_tree_index_1d_coords() -> None:
+    ds = xr.Dataset(
+        {"temp": ("points", np.arange(20, dtype=float))},
+        coords={
+            "x": ("points", np.linspace(0, 10, 20)),
+            "y": ("points", np.linspace(15, 8, 20)),
+        },
+    )
+    ds_indexed = ds.set_xindex(("x", "y"), NDPointIndex)
+
+    assert isinstance(ds_indexed.xindexes["x"], NDPointIndex)
+
+    actual = ds_indexed.sel(x=5.0, y=11.5, method="nearest")
+    assert actual.temp.values == 10.0
+
+    actual = ds_indexed.sel(
+        x=xr.Variable("query", [0.0, 5.0, 10.0]),
+        y=xr.Variable("query", [15.0, 11.5, 8.0]),
+        method="nearest",
+    )
+    expected = xr.Dataset(
+        {"temp": ("query", [0.0, 10.0, 19.0])},
+        coords={
+            "x": ("query", [0.0, ds.x.values[10], 10.0]),
+            "y": ("query", [15.0, ds.y.values[10], 8.0]),
+        },
+    )
+    assert_identical(actual, expected)

diff --git a/xarray/indexes/nd_point_index.py b/xarray/indexes/nd_point_index.py
index 1469d59e4fa..e00e6ad608a 100644
--- a/xarray/indexes/nd_point_index.py
+++ b/xarray/indexes/nd_point_index.py
@@ -244,7 +244,7 @@ def __init__(
         assert isinstance(tree_obj, TreeAdapter)
         self._tree_obj = tree_obj

-        assert len(coord_names) == len(dims) == len(shape)
+        assert len(dims) == len(shape)
         self._coord_names = coord_names
         self._dims = dims
         self._shape = shape
@@ -264,12 +264,6 @@ def from_variables(

         var0 = next(iter(variables.values()))

-        if len(variables) != len(var0.dims):
-            raise ValueError(
-                f"the number of variables {len(variables)} doesn't match "
-                f"the number of dimensions {len(var0.dims)}"
-            )
-
         opts = dict(options)

         tree_adapter_cls: type[T_TreeAdapter] = opts.pop("tree_adapter_cls", None)


# sortby descending does not handle nans correctly

### What happened?

sortby ascending=False just returns the reversed output of ascending=True
This is incorrect if the array contains nan values. (< is numerically not the opposite of > for nan values)

For example:
```python
a = xr.DataArray([3, np.nan, 4, 2, np.nan],
                 {"label": ["a", "b", "c", "d", "e"]})
a.sortby(a, ascending=False)
```
produces
```
[nan, nan,  4.,  3.,  2.]
```

### What did you expect to happen?

Using the example above, I expect to see:
```
[ 4.,  3.,  2., nan, nan]
```

A workaround to return the correct result can be implemented like this:
```python
idx = np.argpartition(-a, np.arange((np.isfinite(a)).sum())).values
a[idx]
```

### Minimal Complete Verifiable Example

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

### Relevant log output

_No response_

### Anything else we need to know?

_No response_

### Environment

```
xarray                    2022.11.0       py310hecd8cb5_0
```

diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
index 2848ae5a7be..ec68cf1fc02 100644
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -7236,6 +7236,47 @@ def test_sortby(self) -> None:
         actual = ds.sortby(["x", "y"], ascending=False)
         assert_equal(actual, ds)

+    def test_sortby_descending_nans(self) -> None:
+        # Regression test for https://github.com/pydata/xarray/issues/7358
+        # NaN values should remain at the end when sorting in descending order
+        ds = Dataset({"var": ("x", [3.0, np.nan, 4.0, 2.0, np.nan])})
+
+        # Ascending: NaNs at end
+        result_asc = ds.sortby("var", ascending=True)
+        assert_array_equal(result_asc["var"].values[:3], [2.0, 3.0, 4.0])
+        assert np.all(np.isnan(result_asc["var"].values[3:]))
+
+        # Descending: NaNs should also be at end (not beginning)
+        result_desc = ds.sortby("var", ascending=False)
+        assert_array_equal(result_desc["var"].values[:3], [4.0, 3.0, 2.0])
+        assert np.all(np.isnan(result_desc["var"].values[3:]))
+
+    def test_sortby_descending_nans_multi_key(self) -> None:
+        # Test sortby with multiple keys where one has NaN values
+        # Regression test for https://github.com/pydata/xarray/issues/7358
+        ds = Dataset(
+            {
+                "A": (("x", "y"), [[1, 2, 3], [4, 5, 6]]),
+                "B": (("x", "y"), [[7, 8, 9], [10, 11, 12]]),
+            },
+            coords={"x": ["b", "a"], "y": [np.nan, 1, 0]},
+        )
+
+        # Sort by multiple keys in descending order
+        result = ds.sortby(["x", "y"], ascending=False)
+
+        # x should be sorted descending: ["b", "a"]
+        assert_array_equal(result["x"].values, ["b", "a"])
+
+        # y should be sorted descending with NaN at end: [1, 0, nan]
+        assert_array_equal(result["y"].values[:2], [1, 0])
+        assert np.isnan(result["y"].values[2])
+
+        # Verify data is reordered correctly
+        # Original y=[nan, 1, 0] -> sorted y=[1, 0, nan] means columns reordered [1, 2, 0]
+        assert_array_equal(result["A"].values, [[2, 3, 1], [5, 6, 4]])
+        assert_array_equal(result["B"].values, [[8, 9, 7], [11, 12, 10]])
+
     def test_attribute_access(self) -> None:
         ds = create_test_data(seed=1)
         for key in ["var1", "var2", "var3", "time", "dim1", "dim2", "dim3", "numbers"]:

diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
index 425a3dc19cb..e22c080f159 100644
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -8129,8 +8129,21 @@ def sortby(

         indices = {}
         for key, arrays in vars_by_dim.items():
-            order = np.lexsort(tuple(reversed(arrays)))
-            indices[key] = order if ascending else order[::-1]
+            if ascending:
+                indices[key] = np.lexsort(tuple(reversed(arrays)))
+            else:
+                # For descending order, we need to keep NaNs at the end.
+                # By adding notnull(arr) as additional sort keys, null values
+                # sort to the beginning (False=0 < True=1), then reversing
+                # puts them at the end. See https://github.com/pydata/xarray/issues/7358
+                indices[key] = np.lexsort(
+                    tuple(
+                        [
+                            *reversed(arrays),
+                            *[duck_array_ops.notnull(arr) for arr in reversed(arrays)],
+                        ]
+                    )
+                )[::-1]
         return aligned_self.isel(indices)

     def quantile(


# BUG: normalize_indexer breaks _decompose_slice

### What happened?

With `xarray==2025.12.0`, the rioxarray tests started to fail:

https://github.com/corteva/rioxarray/actions/runs/19997389961/job/57347087949

The issue was traced back to this change #10948.

### What did you expect to happen?

No error.

### Minimal Complete Verifiable Example

```Python
# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "xarray[complete]@git+https://github.com/pydata/xarray.git@main",
# ]
# ///
#
# 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 xarray as xr
xr.show_versions()
from xarray.core.indexing import normalize_indexer, _decompose_slice
print(_decompose_slice(slice(-1, None, -1), 8))
# (slice(0, 8, 1), slice(None, None, -1))
print(_decompose_slice(normalize_indexer(slice(-1, None, -1), 8), 8))
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
#     _decompose_slice(normalize_indexer(slice(-1, None, -1), 8), 8)
#     ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#   File ".../lib/python3.14/site-packages/xarray/core/indexing.py", # line 1217, in _decompose_slice
#     exact_stop = range(start, stop, step)[-1]
#                  ~~~~~~~~~~~~~~~~~~~~~~~~^^^^
# IndexError: range object index out of range
```

### 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
=================================== FAILURES ===================================
________________________________ test_indexing _________________________________

    def test_indexing():
...
               # minus-stepped slice
                ind = {"band": numpy.array([2, 1, 0]), "x": slice(-1, None, -1), "y": 0}
>               assert_allclose(expected.isel(**ind), actual.isel(**ind))
...
testenv/lib/python3.12/site-packages/xarray/core/indexing.py:1415: in _decompose_outer_indexer
    bk_slice, np_slice = _decompose_slice(k, s)
                         ^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

key = slice(7, -1, -1), size = 8

    def _decompose_slice(key: slice, size: int) -> tuple[slice, slice]:
            # determine stop precisely for step > 1 case
            # Use the range object to do the calculation
            # e.g. [98:2:-2] -> [98:3:-2]
>           exact_stop = range(start, stop, step)[-1]
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E           IndexError: range object index out of range
```

### Anything else we need to know?

_No response_

### Environment

<details>
https://github.com/corteva/rioxarray/actions/runs/19997389961/job/57347087949

xarray-2025.12.0
cftime-1.6.5
cloudpickle-3.1.2
dask-2025.11.0
fsspec-2025.12.0
netcdf4-1.7.3
</details>

diff --git a/xarray/tests/test_indexing.py b/xarray/tests/test_indexing.py
index fabfec4e038..6b0fcd2f59a 100644
--- a/xarray/tests/test_indexing.py
+++ b/xarray/tests/test_indexing.py
@@ -300,10 +300,11 @@ class TestLazyArray:
             (-1, 3, 2),
             (slice(None), 4, slice(0, 4, 1)),
             (slice(1, -3), 7, slice(1, 4, 1)),
+            (slice(None, None, -1), 8, slice(7, None, -1)),
             (np.array([-1, 3, -2]), 5, np.array([4, 3, 3])),
         ),
     )
-    def normalize_indexer(self, indexer, size, expected):
+    def test_normalize_indexer(self, indexer, size, expected):
         actual = indexing.normalize_indexer(indexer, size)

         if isinstance(expected, np.ndarray):

diff --git a/xarray/core/indexing.py b/xarray/core/indexing.py
index 59c9807f588..bb12704e55c 100644
--- a/xarray/core/indexing.py
+++ b/xarray/core/indexing.py
@@ -269,8 +269,11 @@ def normalize_slice(sl: slice, size: int) -> slice:
     slice(0, 9, 1)
     >>> normalize_slice(slice(0, -1), 10)
     slice(0, 9, 1)
+    >>> normalize_slice(slice(None, None, -1), 10)
+    slice(9, None, -1)
     """
-    return slice(*sl.indices(size))
+    start, stop, step = sl.indices(size)
+    return slice(start, stop if stop >= 0 else None, step)

 def _expand_slice(slice_: slice, size: int) -> np.ndarray[Any, np.dtype[np.integer]]:
@@ -284,8 +287,8 @@ def _expand_slice(slice_: slice, size: int) -> np.ndarray[Any, np.dtype[np.integ
     >>> _expand_slice(slice(0, -1), 10)
     array([0, 1, 2, 3, 4, 5, 6, 7, 8])
     """
-    sl = normalize_slice(slice_, size)
-    return np.arange(sl.start, sl.stop, sl.step)
+    start, stop, step = slice_.indices(size)
+    return np.arange(start, stop, step)

 def slice_slice(old_slice: slice, applied_slice: slice, size: int) -> slice:
@@ -293,14 +296,14 @@ def slice_slice(old_slice: slice, applied_slice: slice, size: int) -> slice:
     index it with another slice to return a new slice equivalent to applying
     the slices sequentially
     """
-    old_slice = normalize_slice(old_slice, size)
+    old_slice = slice(*old_slice.indices(size))

     size_after_old_slice = len(range(old_slice.start, old_slice.stop, old_slice.step))
     if size_after_old_slice == 0:
         # nothing left after applying first slice
         return slice(0)

-    applied_slice = normalize_slice(applied_slice, size_after_old_slice)
+    applied_slice = slice(*applied_slice.indices(size_after_old_slice))

     start = old_slice.start + applied_slice.start * old_slice.step
     if start < 0:


# Unexpected keyword error in `dataarray_plot.py`

### What happened?

When I tried to plot 2D flow field (Time * Nx * Ny np array), I got "**AxesImage.set() got an unexpected keyword argument 'msg'**", which I found came from `dataarray_plot._plot2d.newplotfunc`. Here is the detail:

---

OS: Ubuntu 24.04 LTS, WSL2
python version: 3.12.8
numpy version: 2.2.6
xarray version: 2025.3.0
matplotlib version: 3.10.0 (newest on Jan. 26 2026)

---

My code snippet:
```
# load into xarray for visualization and analysis
ds = xarray.Dataset(
    {
        'u': (('time', 'x', 'y'), trajectory[0].data), # (10, 128, 128) array
        'v': (('time', 'x', 'y'), trajectory[1].data), # (10, 128, 128) array
    },
    coords={
        'x': grid.axes()[0], # (128,) array
        'y': grid.axes()[1], # (128,) array
        'time': dt * inner_steps * np.arange(1, outer_steps + 1) # (10,) array
    }
)

ds["vorticity"] = ds["v"].differentiate("x") - ds["u"].differentiate("y") # (10, 128, 128) array

# Plot the x-velocity
ds.pipe(lambda ds: ds.u).plot.imshow('x', 'y', col='time', cmap=seaborn.cm.rocket, robust=True, col_wrap=4, aspect=2)
```

The error message is attached below.

### What did you expect to happen?

_No response_

### Minimal Complete Verifiable Example

```Python
# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "xarray[complete]@git+https://github.com/pydata/xarray.git@main",
# ]
# ///
#
# 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 xarray as xr
xr.show_versions()
# your reproducer code ...
# load into xarray for visualization and analysis
ds = xarray.Dataset(
    {
        'u': (('time', 'x', 'y'), np.random.randn(10, 128, 128)), # (10, 128, 128) array
        'v': (('time', 'x', 'y'), np.random.randn(10, 128, 128)), # (10, 128, 128) array
    },
    coords={
        'x': np.linspace(0, 2*np.pi, 128), # (128,) array
        'y': np.linspace(0, 2*np.pi, 128), # (128,) array
        'time': np.linspace(0, 1, 10) # (10,) array
    }
)

ds.u.plot.imshow('x', 'y', col='time')
```

### Steps to reproduce

Just run the minimal complete verifiable example.

### 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.
- [ ] Recent environment — the issue occurs with the latest version of xarray and its dependencies.

### Relevant log output

```Python
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[20], line 14
      1 # load into xarray for visualization and analysis
      2 ds = xarray.Dataset(
      3     {
      4         'u': (('time', 'x', 'y'), np.random.randn(10, 128, 128)), # (10, 128, 128) array
   (...)     11     }
     12 )
---> 14 ds.u.plot.imshow('x', 'y', col='time')

File ***/site-packages/xarray/plot/accessor.py:421, in DataArrayPlotAccessor.imshow(self, *args, **kwargs)
    419 @functools.wraps(dataarray_plot.imshow, assigned=("__doc__",))
    420 def imshow(self, *args, **kwargs) -> AxesImage | FacetGrid[DataArray]:
--> 421     return dataarray_plot.imshow(self._da, *args, **kwargs)

File ***/site-packages/xarray/plot/dataarray_plot.py:1507, in _plot2d.<locals>.newplotfunc(***failed resolving arguments***)
   1505     # Need the decorated plotting function
   1506     allargs["plotfunc"] = globals()[plotfunc.__name__]
-> 1507     return _easy_facetgrid(darray, kind="dataarray", **allargs)
   1509 if darray.ndim == 0 or darray.size == 0:
   1510     # TypeError to be consistent with pandas
   1511     raise TypeError("No numeric data to plot.")

File ***/site-packages/xarray/plot/facetgrid.py:1072, in _easy_facetgrid(data, plotfunc, kind, x, y, row, col, col_wrap, sharex, sharey, aspect, size, subplot_kws, ax, figsize, **kwargs)
   1069     return g.map_dataarray_line(plotfunc, x, y, **kwargs)
   1071 if kind == "dataarray":
-> 1072     return g.map_dataarray(plotfunc, x, y, **kwargs)
   1074 if kind == "plot1d":
   1075     return g.map_plot1d(plotfunc, x, y, **kwargs)

File ***/site-packages/xarray/plot/facetgrid.py:373, in FacetGrid.map_dataarray(self, func, x, y, **kwargs)
    371     if d is not None:
    372         subset = self.data.loc[d]
--> 373         mappable = func(
    374             subset, x=x, y=y, ax=ax, **func_kwargs, _is_facetgrid=True
    375         )
    376         self._mappables.append(mappable)
    378 self._finalize_grid(x, y)

File ***/site-packages/xarray/plot/dataarray_plot.py:1607, in _plot2d.<locals>.newplotfunc(***failed resolving arguments***)
   1603     raise ValueError("plt.imshow's `aspect` kwarg is not available in xarray")
   1605 ax = get_axis(figsize, size, aspect, ax, **subplot_kws)
-> 1607 primitive = plotfunc(
   1608     xplt,
   1609     yplt,
   1610     zval,
   1611     ax=ax,
   1612     cmap=cmap_params["cmap"],
   1613     vmin=cmap_params["vmin"],
   1614     vmax=cmap_params["vmax"],
   1615     norm=cmap_params["norm"],
   1616     **kwargs,
   1617 )
   1619 # Label the plot with metadata
   1620 if add_labels:

File ***/site-packages/xarray/plot/dataarray_plot.py:1866, in imshow(x, y, z, ax, **kwargs)
   1863         z = z.copy()
   1864     z[np.any(z.mask, axis=-1), -1] = 0
-> 1866 primitive = ax.imshow(z, **defaults)
   1868 # If x or y are strings the ticklabels have been replaced with
   1869 # integer indices. Replace them back to strings:
   1870 for axis, v in [("x", x), ("y", y)]:

File ***/site-packages/matplotlib/__init__.py:1521, in _preprocess_data.<locals>.inner(ax, data, *args, **kwargs)
   1518 @functools.wraps(func)
   1519 def inner(ax, *args, data=None, **kwargs):
   1520     if data is None:
-> 1521         return func(
   1522             ax,
   1523             *map(cbook.sanitize_sequence, args),
   1524             **{k: cbook.sanitize_sequence(v) for k, v in kwargs.items()})
   1526     bound = new_sig.bind(ax, *args, **kwargs)
   1527     auto_label = (bound.arguments.get(label_namer)
   1528                   or bound.kwargs.get(label_namer))

File ***/site-packages/matplotlib/axes/_axes.py:5931, in Axes.imshow(self, X, cmap, norm, aspect, interpolation, alpha, vmin, vmax, colorizer, origin, extent, interpolation_stage, filternorm, filterrad, resample, url, **kwargs)
   5713 @_preprocess_data()
   5714 @_docstring.interpd
   5715 def imshow(self, X, cmap=None, norm=None, *, aspect=None,
   (...)   5718            interpolation_stage=None, filternorm=True, filterrad=4.0,
   5719            resample=None, url=None, **kwargs):
   5720     """
   5721     Display data as an image, i.e., on a 2D regular raster.
   5722
   (...)   5929     (unassociated) alpha representation.
   5930     """
-> 5931     im = mimage.AxesImage(self, cmap=cmap, norm=norm, colorizer=colorizer,
   5932                           interpolation=interpolation, origin=origin,
   5933                           extent=extent, filternorm=filternorm,
   5934                           filterrad=filterrad, resample=resample,
   5935                           interpolation_stage=interpolation_stage,
   5936                           **kwargs)
   5938     if aspect is None and not (
   5939             im.is_transform_set()
   5940             and not im.get_transform().contains_branch(self.transData)):
   5941         aspect = mpl.rcParams['image.aspect']

File ***/site-packages/matplotlib/image.py:874, in AxesImage.__init__(self, ax, cmap, norm, colorizer, interpolation, origin, extent, filternorm, filterrad, resample, interpolation_stage, **kwargs)
    857 def __init__(self, ax,
    858              *,
    859              cmap=None,
   (...)    869              **kwargs
    870              ):
    872     self._extent = extent
--> 874     super().__init__(
    875         ax,
    876         cmap=cmap,
    877         norm=norm,
    878         colorizer=colorizer,
    879         interpolation=interpolation,
    880         origin=origin,
    881         filternorm=filternorm,
    882         filterrad=filterrad,
    883         resample=resample,
    884         interpolation_stage=interpolation_stage,
    885         **kwargs
    886     )

File ***/site-packages/matplotlib/image.py:277, in _ImageBase.__init__(self, ax, cmap, norm, colorizer, interpolation, origin, filternorm, filterrad, resample, interpolation_stage, **kwargs)
    273 self.axes = ax
    275 self._imcache = None
--> 277 self._internal_update(kwargs)

File ***/site-packages/matplotlib/artist.py:1233, in Artist._internal_update(self, kwargs)
   1226 def _internal_update(self, kwargs):
   1227     """
   1228     Update artist properties without prenormalizing them, but generating
   1229     errors as if calling `set`.
   1230
   1231     The lack of prenormalization is to maintain backcompatibility.
   1232     """
-> 1233     return self._update_props(
   1234         kwargs, "{cls.__name__}.set() got an unexpected keyword argument "
   1235         "{prop_name!r}")

File ***/site-packages/matplotlib/artist.py:1206, in Artist._update_props(self, props, errfmt)
   1204             func = getattr(self, f"set_{k}", None)
   1205             if not callable(func):
-> 1206                 raise AttributeError(
   1207                     errfmt.format(cls=type(self), prop_name=k),
   1208                     name=k)
   1209             ret.append(func(v))
   1210 if ret:

AttributeError: AxesImage.set() got an unexpected keyword argument 'msg'
```

### Anything else we need to know?

In `dataarray_plot._plot2d.newplotfunc`, this line of code `allargs = locals().copy()` added the local variable into `allargs`, which contains key `msg` and triggers the error from matplotlib.

Even though I am not using the newest version, I find this line of code still exists in the latest release.

### Environment

<details>

INSTALLED VERSIONS
------------------
commit: None
python: 3.12.8 | packaged by Anaconda, Inc. | (main, Dec 11 2024, 16:31:09) [GCC 11.2.0]
python-bits: 64
OS: Linux
OS-release: 6.6.87.2-microsoft-standard-WSL2
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: None
LANG: C.UTF-8
LOCALE: ('C', 'UTF-8')
libhdf5: 1.14.6
libnetcdf: 4.9.3

xarray: 2025.3.0
pandas: 2.2.3
numpy: 2.2.6
scipy: 1.15.3
netCDF4: 1.7.4
pydap: None
h5netcdf: None
h5py: None
zarr: None
cftime: 1.6.5
nc_time_axis: None
iris: None
bottleneck: None
dask: None
distributed: None
matplotlib: 3.10.0
cartopy: None
seaborn: 0.13.2
numbagg: None
fsspec: 2025.5.1
cupy: None
pint: None
sparse: None
flox: None
numpy_groupies: None
setuptools: 78.1.1
pip: 25.3
conda: None
pytest: None
mypy: None
IPython: 9.2.0
sphinx: None

</details>

diff --git a/xarray/tests/test_plot.py b/xarray/tests/test_plot.py
index bb0b809448c..61d742a901b 100644
--- a/xarray/tests/test_plot.py
+++ b/xarray/tests/test_plot.py
@@ -834,6 +834,14 @@ def test_slice_in_title_single_item_array(self) -> None:
         title = plt.gca().get_title()
         assert "d = [10.009]" == title

+    def test_warns_for_few_positional_args(self) -> None:
+        with pytest.warns(DeprecationWarning, match="Using positional arguments"):
+            self.darray.plot.scatter("period")
+
+    def test_raises_for_too_many_positional_args(self) -> None:
+        with pytest.raises(ValueError, match="Using positional arguments"):
+            self.darray.plot.scatter("period", "foo", "bar", "blue", {})
+

 class TestPlotStep(PlotTestCase):
     @pytest.fixture(autouse=True)
@@ -1730,6 +1738,19 @@ def test_colormap_error_norm_and_vmin_vmax(self) -> None:
         with pytest.raises(ValueError):
             self.darray.plot(norm=norm, vmax=2)  # type: ignore[call-arg]

+    def test_plot_warns_for_2_positional_args(self) -> None:
+        da = xr.DataArray(
+            np.random.randn(2, 6, 6),
+            dims=("time", "x", "y"),
+            coords={"x": np.arange(6), "y": np.arange(6)},
+        )
+        with pytest.warns(DeprecationWarning, match="Using positional arguments"):
+            self.plotfunc(da, "x", "y", col="time")
+
+    def test_plot_raises_too_many_for_positional_args(self) -> None:
+        with pytest.raises(ValueError, match="Using positional arguments"):
+            self.plotmethod("x", "y", (12, 4))
+

 @pytest.mark.slow
 class TestContourf(Common2dMixin, PlotTestCase):
@@ -2890,6 +2911,10 @@ def test_bad_args(
                 x=x, y=y, hue=hue, add_legend=add_legend, add_colorbar=add_colorbar
             )

+    def test_does_not_allow_positional_args(self) -> None:
+        with pytest.raises(TypeError, match="takes 1 positional argument"):
+            self.ds.plot.scatter("A", "B")
+
     def test_datetime_hue(self) -> None:
         ds2 = self.ds.copy()

diff --git a/xarray/plot/dataarray_plot.py b/xarray/plot/dataarray_plot.py
index 3a140ad46ca..c64e1453e16 100644
--- a/xarray/plot/dataarray_plot.py
+++ b/xarray/plot/dataarray_plot.py
@@ -641,7 +641,7 @@ def step(

 def hist(
     darray: DataArray,
-    *args: Any,
+    *,
     figsize: Iterable[float] | None = None,
     size: float | None = None,
     aspect: AspectOptions = None,
@@ -695,8 +695,6 @@ def hist(
         Additional keyword arguments to :py:func:`matplotlib:matplotlib.pyplot.hist`.

     """
-    assert len(args) == 0
-
     if darray.ndim == 0 or darray.size == 0:
         # TypeError to be consistent with pandas
         raise TypeError("No numeric data to plot.")
@@ -928,6 +926,7 @@ def newplotfunc(
                 raise ValueError(msg)
             else:
                 warnings.warn(msg, DeprecationWarning, stacklevel=2)
+            del msg
         del args

         if hue_style is not None:
@@ -1461,6 +1460,7 @@ def newplotfunc(
                 raise ValueError(msg)
             else:
                 warnings.warn(msg, DeprecationWarning, stacklevel=2)
+            del msg
         del args

         # Decide on a default for the colorbar before facetgrids
diff --git a/xarray/plot/dataset_plot.py b/xarray/plot/dataset_plot.py
index ff508ee213c..bc5ed49e5c9 100644
--- a/xarray/plot/dataset_plot.py
+++ b/xarray/plot/dataset_plot.py
@@ -213,6 +213,7 @@ def newplotfunc(
                 raise ValueError(msg)
             else:
                 warnings.warn(msg, DeprecationWarning, stacklevel=2)
+            del msg
         del args

         _is_facetgrid = kwargs.pop("_is_facetgrid", False)
@@ -752,7 +753,7 @@ def _temp_dataarray(ds: Dataset, y: Hashable, locals_: dict[str, Any]) -> DataAr
 @overload
 def scatter(  # type: ignore[misc,unused-ignore]  # None is hashable :(
     ds: Dataset,
-    *args: Any,
+    *,
     x: Hashable | None = None,
     y: Hashable | None = None,
     z: Hashable | None = None,
@@ -793,7 +794,7 @@ def scatter(  # type: ignore[misc,unused-ignore]  # None is hashable :(
 @overload
 def scatter(
     ds: Dataset,
-    *args: Any,
+    *,
     x: Hashable | None = None,
     y: Hashable | None = None,
     z: Hashable | None = None,
@@ -834,7 +835,7 @@ def scatter(
 @overload
 def scatter(
     ds: Dataset,
-    *args: Any,
+    *,
     x: Hashable | None = None,
     y: Hashable | None = None,
     z: Hashable | None = None,
@@ -875,7 +876,7 @@ def scatter(
 @_update_doc_to_dataset(dataarray_plot.scatter)
 def scatter(
     ds: Dataset,
-    *args: Any,
+    *,
     x: Hashable | None = None,
     y: Hashable | None = None,
     z: Hashable | None = None,


# Saving to zarr-store using shards changes underlying data

### What happened?

Hey!
I tried saving a dataset (CMIP6 derived data) with very small chunks (dask array) to a zarr store using shards to limit the number of files used in the store. But, I noticed that computation based on the sharded store returned different results compared to the results from a store with the same data, but no shards.

### What did you expect to happen?

I'm expecting the store using shards to represent the same data as the store not using shards, and any following computations would be identical.

### Minimal Complete Verifiable Example

```Python
import xarray as xr
import dask.array as da
from dask.distributed import Client

client = Client()
rng = da.random.default_rng(seed=42)

# Generate some moderately size data to save to disk.
# Chunksize should be (255, 255, 255)
test_data = rng.integers(0, 2, size=(10000, 300, 300))

# Put in DataArray.
test_data = xr.DataArray(test_data)
test_data.name = "test_data"

#Setup encoding for shards.
encoding = {
    "test_data": {
        "shards": (255*2, 255, 255),
    }
}

# Save to disk.
test_data.to_zarr("test_random.zarr", zarr_format=3, mode="w")
test_data.to_zarr("test_random_shard.zarr", zarr_format=3, encoding=encoding, mode="w")

# Read from disk
test_data = xr.open_zarr("test_random.zarr").test_data
test_data_shard = xr.open_zarr("test_random_shard.zarr").test_data

#Expect this to return true, but it doesn't.
assert test_data.sum().compute().values == test_data_shard.sum().compute().values
```

### Steps to reproduce

Note that setting shards to `(255*1, 255, 255) for the encoding above makes the assert return true.

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

Maybe this a zarr issue. Happy to raise there instead if that is the case.

### Environment

<details>
INSTALLED VERSIONS
------------------
commit: None
python: 3.12.11 | packaged by conda-forge | (main, Jun  4 2025, 14:45:31) [GCC 13.3.0]
python-bits: 64
OS: Linux
OS-release: 6.17.0-2-default
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: None
LANG: en_US.UTF-8
LOCALE: ('en_US', 'UTF-8')
libhdf5: 1.14.6
libnetcdf: 4.9.3

xarray: 2025.10.1
pandas: 2.3.2
numpy: 2.2.6
scipy: 1.16.1
netCDF4: 1.7.2
pydap: None
h5netcdf: None
h5py: None
zarr: 3.1.3
cftime: 1.6.4
nc_time_axis: 1.4.1
iris: None
bottleneck: 1.5.0
dask: 2025.9.1
distributed: 2025.9.1
matplotlib: 3.10.6
cartopy: 0.24.0
seaborn: None
numbagg: 0.9.2
fsspec: 2025.9.0
cupy: None
pint: None
sparse: 0.17.0
flox: 0.10.6
numpy_groupies: 0.11.3
setuptools: 80.9.0
pip: None
conda: None
pytest: None
mypy: None
IPython: 9.5.0
sphinx: None

</details>

diff --git a/xarray/tests/test_backends.py b/xarray/tests/test_backends.py
index 3d694d7791a..d4a39cfcf73 100644
--- a/xarray/tests/test_backends.py
+++ b/xarray/tests/test_backends.py
@@ -2869,6 +2869,41 @@ def test_shard_encoding(self) -> None:
                 with self.roundtrip(data) as actual:
                     pass

+    @requires_dask
+    def test_shard_encoding_with_dask(self) -> None:
+        # Test that dask chunks must align with shard boundaries.
+        # See https://github.com/pydata/xarray/issues/10831
+        if not (has_zarr_v3 and zarr.config.config["default_zarr_format"] == 3):
+            pytest.skip("sharding requires zarr v3 format")
+
+        ds = xr.DataArray(np.arange(12), dims="x", name="var1").to_dataset()
+
+        # Case 1: Dask chunks equal to shards should work
+        # (zarr chunk=3, shard=6, dask chunk=6)
+        ds1 = ds.chunk({"x": 6})
+        ds1["var1"].encoding = {"chunks": (3,), "shards": (6,)}
+        with self.roundtrip(ds1) as actual:
+            assert_identical(ds, actual)
+
+        # Case 2: Dask chunks that are multiples of shards should work
+        # (zarr chunk=1, shard=3, dask chunk=6)
+        ds2 = ds.chunk({"x": 6})
+        ds2["var1"].encoding = {"chunks": (1,), "shards": (3,)}
+        with self.roundtrip(ds2) as actual:
+            assert_identical(ds, actual)
+
+        # Case 3: Dask chunks smaller than shards should fail
+        # (zarr chunk=2, shard=4, dask chunk=3) - dask chunk doesn't align with shard
+        ds3 = ds.chunk({"x": 3})
+        ds3["var1"].encoding = {"chunks": (2,), "shards": (4,)}
+        with pytest.raises(ValueError, match=r"would overlap"):
+            with self.roundtrip(ds3) as actual:
+                pass
+
+        # Case 4: Can bypass with safe_chunks=False (but data may be corrupted)
+        with self.roundtrip(ds3, save_kwargs={"safe_chunks": False}) as actual:
+            pass
+
     @requires_dask
     @pytest.mark.skipif(
         ON_WINDOWS,

diff --git a/xarray/backends/zarr.py b/xarray/backends/zarr.py
index af2aad34d5e..1eadace1d0e 100644
--- a/xarray/backends/zarr.py
+++ b/xarray/backends/zarr.py
@@ -1240,16 +1240,22 @@ def set_variables(
                 zarr_format=3 if is_zarr_v3_format else 2,
             )

-            if self._align_chunks and isinstance(encoding["chunks"], tuple):
+            # When shards are specified, dask chunks must align with shard boundaries
+            # (not just zarr chunk boundaries) to avoid data corruption during
+            # parallel writes. See https://github.com/pydata/xarray/issues/10831
+            effective_write_chunks = encoding.get("shards") or encoding["chunks"]
+
+            if self._align_chunks and isinstance(effective_write_chunks, tuple):
                 v = grid_rechunk(
                     v=v,
-                    enc_chunks=encoding["chunks"],
+                    enc_chunks=effective_write_chunks,
                     region=region,
                 )

-            if self._safe_chunks and isinstance(encoding["chunks"], tuple):
+            if self._safe_chunks and isinstance(effective_write_chunks, tuple):
                 # the hard case
                 # DESIGN CHOICE: do not allow multiple dask chunks on a single zarr chunk
+                # (or shard, when sharding is enabled)
                 # this avoids the need to get involved in zarr synchronization / locking
                 # From zarr docs:
                 #  "If each worker in a parallel computation is writing to a
@@ -1260,7 +1266,7 @@ def set_variables(
                 shape = zarr_shape or v.shape
                 validate_grid_chunks_alignment(
                     nd_v_chunks=v.chunks,
-                    enc_chunks=encoding["chunks"],
+                    enc_chunks=effective_write_chunks,
                     region=region,
                     allow_partial_chunks=self._mode != "r+",
                     name=name,


# End of deprecation cycle: default to using new combine kwargs

It's been almost 6 months and I haven't seen any reports of issues. All the original PR (#10062) did was raise `FutureWarnings` though, so this could still have an impact on people who ignored those.

- [x] Closes #8778
- [x] Tests added
- [ ] User visible changes (including notable bug fixes) are documented in `whats-new.rst`
- [ ] ~New functions/methods are listed in `api.rst`~

diff --git a/xarray/tests/test_concat.py b/xarray/tests/test_concat.py
index 5207ee3316e..bc98d72d50c 100644
--- a/xarray/tests/test_concat.py
+++ b/xarray/tests/test_concat.py
@@ -979,8 +979,12 @@ def test_concat_do_not_promote(self) -> None:
             Dataset({"y": ("t", [1])}, {"x": 1, "t": [0]}),
             Dataset({"y": ("t", [2])}, {"x": 2, "t": [0]}),
         ]
-        with pytest.raises(ValueError):
-            concat(objs, "t", coords="minimal")
+        with set_options(use_new_combine_kwarg_defaults=False):
+            with pytest.raises(ValueError):
+                concat(objs, "t", coords="minimal")
+        with set_options(use_new_combine_kwarg_defaults=True):
+            with pytest.raises(ValueError):
+                concat(objs, "t", compat="equals")

     def test_concat_dim_is_variable(self) -> None:
         objs = [Dataset({"x": 0}), Dataset({"x": 1})]
@@ -1664,8 +1668,17 @@ def test_concat_datatree_along_existing_dim_defaults(self):
             FutureWarning, match="will change from data_vars='all' to data_vars=None"
         ):
             actual = concat([dt1, dt2], dim="x")
+
         assert actual.identical(expected)

+        with set_options(use_new_combine_kwarg_defaults=True):
+            expected = DataTree.from_dict(
+                data={"/a": ("x", [1, 2]), "/b": 3}, coords={"/x": [0, 1]}
+            )
+            actual = concat([dt1, dt2], dim="x")
+
+            assert actual.identical(expected)
+
     def test_concat_datatree_isomorphic_error(self):
         dt1 = DataTree.from_dict(data={"/data": ("x", [1]), "/a": None})
         dt2 = DataTree.from_dict(data={"/data": ("x", [2]), "/b": None})
diff --git a/xarray/tests/test_dask.py b/xarray/tests/test_dask.py
index 2d103994410..f00e945d0fe 100644
--- a/xarray/tests/test_dask.py
+++ b/xarray/tests/test_dask.py
@@ -471,7 +471,16 @@ def test_concat_loads_variables(self):
         assert isinstance(out["d"].data, dask.array.Array)
         assert isinstance(out["c"].data, dask.array.Array)

-        out = xr.concat([ds1, ds2, ds3], dim="n", data_vars=[], coords=[])
+        with xr.set_options(use_new_combine_kwarg_defaults=True):
+            out = xr.concat([ds1, ds2, ds3], dim="n", data_vars=[], coords=[])
+            # no extra kernel calls
+            assert kernel_call_count == 6
+            assert isinstance(out["d"].data, dask.array.Array)
+            assert isinstance(out["c"].data, dask.array.Array)
+
+        out = xr.concat(
+            [ds1, ds2, ds3], dim="n", data_vars=[], coords=[], compat="equals"
+        )
         # variables are loaded once as we are validating that they're identical
         assert kernel_call_count == 12
         assert isinstance(out["d"].data, np.ndarray)
diff --git a/xarray/tests/test_merge.py b/xarray/tests/test_merge.py
index 68db0babb04..de24b378539 100644
--- a/xarray/tests/test_merge.py
+++ b/xarray/tests/test_merge.py
@@ -528,7 +528,7 @@ def test_merge_coordinates(self):
     def test_merge_error(self):
         ds = xr.Dataset({"x": 0})
         with pytest.raises(xr.MergeError):
-            xr.merge([ds, ds + 1])
+            xr.merge([ds, ds + 1], compat="no_conflicts")

     def test_merge_alignment_error(self):
         ds = xr.Dataset(coords={"x": [1, 2]})
@@ -624,7 +624,7 @@ def test_merge(self):
         assert_identical(data, actual)

         with pytest.raises(ValueError, match="conflicting values for variable"):
-            ds1.merge(ds2.rename({"var3": "var1"}))
+            ds1.merge(ds2.rename({"var3": "var1"}), compat="no_conflicts")
         with pytest.raises(ValueError, match=r"should be coordinates or not"):
             data.reset_coords().merge(data)
         with pytest.raises(ValueError, match=r"should be coordinates or not"):
@@ -948,7 +948,7 @@ def test_merge_error_includes_path(self) -> None:
                 "Raised whilst mapping function over node(s) with path 'a'"
             ),
         ):
-            xr.merge([tree1, tree2], join="exact")
+            xr.merge([tree1, tree2], join="exact", compat="no_conflicts")

     def test_fill_value_errors(self) -> None:
         trees = [xr.DataTree(), xr.DataTree()]
diff --git a/xarray/tests/test_units.py b/xarray/tests/test_units.py
index 56b3d9ad22b..42bf3649202 100644
--- a/xarray/tests/test_units.py
+++ b/xarray/tests/test_units.py
@@ -785,7 +785,7 @@ def test_combine_by_coords(variant, unit, error, dtype):

     if error is not None:
         with pytest.raises(error):
-            xr.combine_by_coords([ds, other])
+            xr.combine_by_coords([ds, other], coords="different", compat="no_conflicts")

         return

@@ -825,12 +825,6 @@ def test_combine_by_coords(variant, unit, error, dtype):
         "coords",
     ),
 )
-@pytest.mark.filterwarnings(
-    "ignore:.*the default value for join will change:FutureWarning"
-)
-@pytest.mark.filterwarnings(
-    "ignore:.*the default value for compat will change:FutureWarning"
-)
 def test_combine_nested(variant, unit, error, dtype):
     original_unit = unit_registry.m

@@ -890,7 +884,7 @@ def test_combine_nested(variant, unit, error, dtype):
         },
     )

-    func = function(xr.combine_nested, concat_dim=["x", "y"])
+    func = function(xr.combine_nested, concat_dim=["x", "y"], join="outer")
     if error is not None:
         with pytest.raises(error):
             func([[ds1, ds2], [ds3, ds4]])
@@ -1071,12 +1065,6 @@ def test_concat_dataset(variant, unit, error, dtype):
         "coords",
     ),
 )
-@pytest.mark.filterwarnings(
-    "ignore:.*the default value for join will change:FutureWarning"
-)
-@pytest.mark.filterwarnings(
-    "ignore:.*the default value for compat will change:FutureWarning"
-)
 def test_merge_dataarray(variant, unit, error, dtype):
     original_unit = unit_registry.m

@@ -1128,9 +1116,10 @@ def test_merge_dataarray(variant, unit, error, dtype):
         dims=("y", "z"),
     )

+    func = function(xr.merge, compat="no_conflicts", join="outer")
     if error is not None:
         with pytest.raises(error):
-            xr.merge([arr1, arr2, arr3])
+            func([arr1, arr2, arr3])

         return

@@ -1146,13 +1135,13 @@ def test_merge_dataarray(variant, unit, error, dtype):
     convert_and_strip = lambda arr: strip_units(convert_units(arr, units))

     expected = attach_units(
-        xr.merge(
+        func(
             [convert_and_strip(arr1), convert_and_strip(arr2), convert_and_strip(arr3)]
         ),
         units,
     )

-    actual = xr.merge([arr1, arr2, arr3])
+    actual = func([arr1, arr2, arr3])

     assert_units_equal(expected, actual)
     assert_allclose(expected, actual)
@@ -1181,12 +1170,6 @@ def test_merge_dataarray(variant, unit, error, dtype):
         "coords",
     ),
 )
-@pytest.mark.filterwarnings(
-    "ignore:.*the default value for join will change:FutureWarning"
-)
-@pytest.mark.filterwarnings(
-    "ignore:.*the default value for compat will change:FutureWarning"
-)
 def test_merge_dataset(variant, unit, error, dtype):
     original_unit = unit_registry.m

@@ -1235,7 +1218,7 @@ def test_merge_dataset(variant, unit, error, dtype):
         },
     )

-    func = function(xr.merge)
+    func = function(xr.merge, compat="no_conflicts", join="outer")
     if error is not None:
         with pytest.raises(error):
             func([ds1, ds2, ds3])
@@ -5607,9 +5590,6 @@ def test_content_manipulation(self, func, variant, dtype):
             "coords",
         ),
     )
-    @pytest.mark.filterwarnings(
-        "ignore:.*the default value for join will change:FutureWarning"
-    )
     @pytest.mark.filterwarnings(
         "ignore:.*the default value for compat will change:FutureWarning"
     )
@@ -5651,13 +5631,15 @@ def test_merge(self, variant, unit, error, dtype):

         if error is not None:
             with pytest.raises(error):
-                left.merge(right)
+                left.merge(right, compat="no_conflicts", join="outer")

             return

         converted = convert_units(right, units)
-        expected = attach_units(strip_units(left).merge(strip_units(converted)), units)
-        actual = left.merge(right)
+        expected = attach_units(
+            strip_units(left).merge(strip_units(converted), join="outer"), units
+        )
+        actual = left.merge(right, join="outer")

         assert_units_equal(expected, actual)
         assert_equal(expected, actual)

diff --git a/xarray/backends/api.py b/xarray/backends/api.py
index 5cb879620cb..85da88f5339 100644
--- a/xarray/backends/api.py
+++ b/xarray/backends/api.py
@@ -1463,7 +1463,7 @@ def open_mfdataset(
         "netcdf4" over "h5netcdf" over "scipy" (customizable via
         ``netcdf_engine_order`` in ``xarray.set_options()``). A custom backend
         class (a subclass of ``BackendEntrypoint``) can also be used.
-    data_vars : {"minimal", "different", "all"} or list of str, default: "all"
+    data_vars : {"minimal", "different", "all", None} or list of str, default: "all"
         These data variables will be concatenated together:
           * "minimal": Only data variables in which the dimension already
             appears are included.
@@ -1473,9 +1473,12 @@ class (a subclass of ``BackendEntrypoint``) can also be used.
             load the data payload of data variables into memory if they are not
             already loaded.
           * "all": All data variables will be concatenated.
+          * None: Means ``"all"`` if ``concat_dim`` is not present in any of
+            the ``objs``, and ``"minimal"`` if ``concat_dim`` is present
+            in any of ``objs``.
           * list of str: The listed data variables will be concatenated, in
             addition to the "minimal" data variables.
-    coords : {"minimal", "different", "all"} or list of str, optional
+    coords : {"minimal", "different", "all"} or list of str, default: "different"
         These coordinate variables will be concatenated together:
          * "minimal": Only coordinates in which the dimension already appears
            are included.
diff --git a/xarray/structure/combine.py b/xarray/structure/combine.py
index b5546e6daf0..d736f896b4a 100644
--- a/xarray/structure/combine.py
+++ b/xarray/structure/combine.py
@@ -509,7 +509,7 @@ def combine_nested(
         Must be the same length as the depth of the list passed to
         ``datasets``.
     compat : {"identical", "equals", "broadcast_equals", \
-              "no_conflicts", "override"}, optional
+              "no_conflicts", "override"}, default: "no_conflicts"
         String indicating how to compare variables of the same name for
         potential merge conflicts:

@@ -522,7 +522,7 @@ def combine_nested(
           must be equal. The returned dataset then contains the combination
           of all non-null values.
         - "override": skip comparing and pick variable from first dataset
-    data_vars : {"minimal", "different", "all" or list of str}, optional
+    data_vars : {"minimal", "different", "all", None} or list of str, default: "all"
         These data variables will be concatenated together:
           * "minimal": Only data variables in which the dimension already
             appears are included.
@@ -532,15 +532,16 @@ def combine_nested(
             load the data payload of data variables into memory if they are not
             already loaded.
           * "all": All data variables will be concatenated.
-          * None: Means ``"all"`` if ``dim`` is not present in any of the ``objs``,
-            and ``"minimal"`` if ``dim`` is present in any of ``objs``.
-          * list of dims: The listed data variables will be concatenated, in
+          * None: Means ``"all"`` if ``concat_dim`` is not present in any of
+            the ``objs``, and ``"minimal"`` if ``concat_dim`` is present
+            in any of ``objs``.
+          * list of str: The listed data variables will be concatenated, in
             addition to the "minimal" data variables.

-    coords : {"minimal", "different", "all" or list of str}, optional
+    coords : {"minimal", "different", "all"} or list of str, default: "different"
         These coordinate variables will be concatenated together:
-          * "minimal": Only coordinates in which the dimension already appears
-            are included. If concatenating over a dimension _not_
+          * "minimal": Only coordinates in which the dimension already
+            appears are included. If concatenating over a dimension _not_
             present in any of the objects, then all data variables will
             be concatenated along that new dimension.
           * "different": Coordinates which are not equal (ignoring attributes)
@@ -550,14 +551,14 @@ def combine_nested(
             loaded.
           * "all": All coordinate variables will be concatenated, except
             those corresponding to other dimensions.
-          * list of Hashable: The listed coordinate variables will be concatenated,
+          * list of str: The listed coordinate variables will be concatenated,
             in addition to the "minimal" coordinates.

     fill_value : scalar or dict-like, optional
         Value to use for newly missing values. If a dict-like, maps
         variable names to fill values. Use a data array's name to
         refer to its values.
-    join : {"outer", "inner", "left", "right", "exact"}, optional
+    join : {"outer", "inner", "left", "right", "exact"}, default: "outer"
         String indicating how to combine differing indexes
         (excluding concat_dim) in objects

@@ -836,7 +837,8 @@ def combine_by_coords(
     data_objects : Iterable of Datasets or DataArrays
         Data objects to combine.

-    compat : {"identical", "equals", "broadcast_equals", "no_conflicts", "override"}, optional
+    compat : {"identical", "equals", "broadcast_equals", "no_conflicts", "override"}, \
+             default: "no_conflicts"
         String indicating how to compare variables of the same name for
         potential conflicts:

@@ -850,7 +852,7 @@ def combine_by_coords(
           of all non-null values.
         - "override": skip comparing and pick variable from first dataset

-    data_vars : {"minimal", "different", "all" or list of str}, optional
+    data_vars : {"minimal", "different", "all", None} or list of str, default: "all"
         These data variables will be concatenated together:

         - "minimal": Only data variables in which the dimension already
@@ -861,18 +863,33 @@ def combine_by_coords(
           load the data payload of data variables into memory if they are not
           already loaded.
         - "all": All data variables will be concatenated.
+        - None: Means ``"all"`` if ``concat_dim`` is not present in any of
+          the ``objs``, and ``"minimal"`` if ``concat_dim`` is present
+          in any of ``objs``.
         - list of str: The listed data variables will be concatenated, in
           addition to the "minimal" data variables.
+    coords : {"minimal", "different", "all"} or list of str, default: "different"
+        These coordinate variables will be concatenated together:

-        If objects are DataArrays, `data_vars` must be "all".
-    coords : {"minimal", "different", "all"} or list of str, optional
-        As per the "data_vars" kwarg, but for coordinate variables.
+        - "minimal": Only coordinates in which the dimension already
+          appears are included. If concatenating over a dimension _not_
+          present in any of the objects, then all data variables will
+          be concatenated along that new dimension.
+        - "different": Coordinates which are not equal (ignoring attributes)
+          across all datasets are also concatenated (as well as all for which
+          dimension already appears). Beware: this option may load the data
+          payload of coordinate variables into memory if they are not already
+          loaded.
+        - "all": All coordinate variables will be concatenated, except
+          those corresponding to other dimensions.
+        - list of str: The listed coordinate variables will be concatenated,
+          in addition to the "minimal" coordinates.
     fill_value : scalar or dict-like, optional
         Value to use for newly missing values. If a dict-like, maps
         variable names to fill values. Use a data array's name to
         refer to its values. If None, raises a ValueError if
         the passed Datasets do not create a complete hypercube.
-    join : {"outer", "inner", "left", "right", "exact"}, optional
+    join : {"outer", "inner", "left", "right", "exact"}, default: "outer"
         String indicating how to combine differing indexes in objects

         - "outer": use the union of object indexes
diff --git a/xarray/structure/concat.py b/xarray/structure/concat.py
index 4ed4d3e3641..5acd769a9b5 100644
--- a/xarray/structure/concat.py
+++ b/xarray/structure/concat.py
@@ -114,7 +114,7 @@ def concat(
         unchanged. If dimension is provided as a Variable, DataArray or Index, its name
         is used as the dimension to concatenate along and the values are added
         as a coordinate.
-    data_vars : {"minimal", "different", "all", None} or list of Hashable, optional
+    data_vars : {"minimal", "different", "all", None} or list of str, default: "all"
         These data variables will be concatenated together:
           * "minimal": Only data variables in which the dimension already
             appears are included.
@@ -126,11 +126,11 @@ def concat(
           * "all": All data variables will be concatenated.
           * None: Means ``"all"`` if ``dim`` is not present in any of the ``objs``,
             and ``"minimal"`` if ``dim`` is present in any of ``objs``.
-          * list of dims: The listed data variables will be concatenated, in
+          * list of str: The listed data variables will be concatenated, in
             addition to the "minimal" data variables.

-        If objects are DataArrays, data_vars must be "all".
-    coords : {"minimal", "different", "all"} or list of Hashable, optional
+        If objects are DataArrays, data_vars must be "all" or None.
+    coords : {"minimal", "different", "all"} or list of str, default: "different"
         These coordinate variables will be concatenated together:
           * "minimal": Only coordinates in which the dimension already appears
             are included.
@@ -141,9 +141,10 @@ def concat(
             loaded.
           * "all": All coordinate variables will be concatenated, except
             those corresponding to other dimensions.
-          * list of Hashable: The listed coordinate variables will be concatenated,
+          * list of str: The listed coordinate variables will be concatenated,
             in addition to the "minimal" coordinates.
-    compat : {"identical", "equals", "broadcast_equals", "no_conflicts", "override"}, optional
+    compat : {"identical", "equals", "broadcast_equals", "no_conflicts", "override"}, \
+             default: "equals"
         String indicating how to compare non-concatenated variables of the same name for
         potential conflicts. This is passed down to merge.

@@ -164,7 +165,7 @@ def concat(
         Value to use for newly missing values. If a dict-like, maps
         variable names to fill values. Use a data array's name to
         refer to its values.
-    join : {"outer", "inner", "left", "right", "exact"}, optional
+    join : {"outer", "inner", "left", "right", "exact"}, default: "outer"
         String indicating how to combine differing indexes
         (excluding dim) in objects


# `NDPointIndex` should fail early and throw nice error if scipy not installed

### What happened?

[NDPointIndex](https://docs.xarray.dev/en/latest/generated/xarray.indexes.NDPointIndex.html#xarray.indexes.NDPointIndex) has a hard dependency on scipy but when you try to use it with a default xarray install with no extras

you get this error:

```
➜  nd-scipy git:(main) ✗ uv run nd_scipy.py
Traceback (most recent call last):
  File "/Users/ian/Documents/dev/testing/nd-scipy/nd_scipy.py", line 19, in <module>
    ds_index = ds.set_xindex(("xx", "yy"), xr.indexes.NDPointIndex)
  File "/Users/ian/.cache/uv/environments-v2/nd-scipy-363973e27d29e760/lib/python3.14/site-packages/xarray/core/dataset.py", line 5019, in set_xindex
    index = index_cls.from_variables(coord_vars, options=options)
  File "/Users/ian/.cache/uv/environments-v2/nd-scipy-363973e27d29e760/lib/python3.14/site-packages/xarray/indexes/nd_point_index.py", line 276, in from_variables
    tree_adapter_cls(points, options=opts),
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ian/.cache/uv/environments-v2/nd-scipy-363973e27d29e760/lib/python3.14/site-packages/xarray/indexes/nd_point_index.py", line 78, in __init__
    from scipy.spatial import KDTree
ModuleNotFoundError: No module named 'scipy'
```

which could be confusing as a user.

### What did you expect to happen?

- An error raised on import of the Index rather only once I try to create one
- More informative error message saying something like "to use NDPointIndex please install scipy"

Similar to how there is a nice message if you are missing a backend:

> ValueError: unrecognized engine 'zarr' must be one of your download engines: ['store']. To install additional dependencies, see:
https://docs.xarray.dev/en/stable/user-guide/io.html
https://docs.xarray.dev/en/stable/getting-started-guide/installing.html

### Minimal Complete Verifiable Example

```Python
# /// script
# requires-python = ">=3.14"
# dependencies = [
#     "xarray",
# ]
# ///

import numpy as np
import xarray as xr
from xarray.indexes import NDPointIndex

shape = (5, 10)
xx = xr.DataArray(np.random.uniform(0, 10, size=shape), dims=("y", "x"))
yy = xr.DataArray(np.random.uniform(0, 5, size=shape), dims=("y", "x"))
data = (xx - 5) ** 2 + (yy - 2.5) ** 2

ds = xr.Dataset(data_vars={"data": data}, coords={"xx": xx, "yy": yy})
ds_index = ds.set_xindex(("xx", "yy"), NDPointIndex)
```

### Steps to reproduce

`uv run`

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

diff --git a/xarray/tests/test_nd_point_index.py b/xarray/tests/test_nd_point_index.py
index bef14bf750d..7c2cb8302c6 100644
--- a/xarray/tests/test_nd_point_index.py
+++ b/xarray/tests/test_nd_point_index.py
@@ -3,11 +3,19 @@

 import xarray as xr
 from xarray.indexes import NDPointIndex
-from xarray.tests import assert_identical
+from xarray.indexes.nd_point_index import ScipyKDTreeAdapter
+from xarray.tests import assert_identical, has_scipy, requires_scipy

-pytest.importorskip("scipy")

+@pytest.mark.skipif(has_scipy, reason="requires scipy to be missing")
+def test_scipy_kdtree_adapter_missing_scipy():
+    points = np.random.rand(4, 2)

+    with pytest.raises(ImportError, match=r"scipy"):
+        ScipyKDTreeAdapter(points, options={})
+
+
+@requires_scipy
 def test_tree_index_init() -> None:
     from xarray.indexes.nd_point_index import ScipyKDTreeAdapter

@@ -26,6 +34,7 @@ def test_tree_index_init() -> None:
     assert ds_indexed1.xindexes["xx"].equals(ds_indexed2.xindexes["yy"])

+@requires_scipy
 def test_tree_index_init_errors() -> None:
     xx, yy = np.meshgrid([1.0, 2.0], [3.0, 4.0])
     ds = xr.Dataset(coords={"xx": (("y", "x"), xx), "yy": (("y", "x"), yy)})
@@ -39,6 +48,7 @@ def test_tree_index_init_errors() -> None:
         ds2.set_xindex(("xx", "yy"), NDPointIndex)

+@requires_scipy
 def test_tree_index_sel() -> None:
     xx, yy = np.meshgrid([1.0, 2.0], [3.0, 4.0])
     ds = xr.Dataset(coords={"xx": (("y", "x"), xx), "yy": (("y", "x"), yy)}).set_xindex(
@@ -112,6 +122,7 @@ def test_tree_index_sel() -> None:
     assert_identical(actual, expected)

+@requires_scipy
 def test_tree_index_sel_errors() -> None:
     xx, yy = np.meshgrid([1.0, 2.0], [3.0, 4.0])
     ds = xr.Dataset(coords={"xx": (("y", "x"), xx), "yy": (("y", "x"), yy)}).set_xindex(
@@ -137,6 +148,7 @@ def test_tree_index_sel_errors() -> None:
         )

+@requires_scipy
 def test_tree_index_equals() -> None:
     xx1, yy1 = np.meshgrid([1.0, 2.0], [3.0, 4.0])
     ds1 = xr.Dataset(

diff --git a/xarray/indexes/nd_point_index.py b/xarray/indexes/nd_point_index.py
index 8d2637a5a3d..1469d59e4fa 100644
--- a/xarray/indexes/nd_point_index.py
+++ b/xarray/indexes/nd_point_index.py
@@ -75,7 +75,13 @@ class ScipyKDTreeAdapter(TreeAdapter):
     _kdtree: KDTree

     def __init__(self, points: np.ndarray, options: Mapping[str, Any]):
-        from scipy.spatial import KDTree
+        try:
+            from scipy.spatial import KDTree
+        except ImportError as err:
+            raise ImportError(
+                "`NDPointIndex` requires `scipy` when used with `ScipyKDTreeAdapter`. "
+                "Please ensure that `scipy` is installed and importable."
+            ) from err

         self._kdtree = KDTree(points, **options)