Repository: pydata/xarray

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

Implementation patch:
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


Test patch:
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, {})