From 4b0339f2082778f2ae2ed00ba1907c80b0b46e02 Mon Sep 17 00:00:00 2001 From: glaziermag Date: Mon, 24 Aug 2026 14:18:23 -0700 Subject: [PATCH 1/3] Fix netCDF4 backend byte-swapping non-native-endian attribute values netCDF4-python does not byte-swap non-native-endian attribute arrays on write, so attributes read from netCDF-3 files with the scipy engine (which returns big-endian arrays) were silently corrupted when written back with the netcdf4 engine (e.g. valid_range [0., 1.] became [0., 3.03865e-319], the byte-swapped bit pattern of 1.0). Convert attribute values to native endianness before writing, matching what _force_native_endianness already does for variable data. Co-Authored-By: Claude Fable 5 --- xarray/backends/netCDF4_.py | 14 +++++++++++++- xarray/tests/test_backends.py | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/xarray/backends/netCDF4_.py b/xarray/backends/netCDF4_.py index af9804669c9..6e04fce3470 100644 --- a/xarray/backends/netCDF4_.py +++ b/xarray/backends/netCDF4_.py @@ -252,6 +252,15 @@ def _force_native_endianness(var): return var +def _force_native_endianness_attr(value): + # netCDF4-python writes non-native-endian attribute arrays without + # byte-swapping, silently corrupting the stored values (variable data + # is handled by _force_native_endianness above). + if isinstance(value, np.ndarray) and value.dtype.byteorder not in ("=", "|"): + value = value.astype(value.dtype.newbyteorder("=")) + return value + + def _extract_nc4_variable_encoding( variable: Variable, raise_on_invalid=False, @@ -632,6 +641,7 @@ def set_dimension(self, name, length, is_unlimited=False): self.ds.createDimension(name, size=dim_length) def set_attribute(self, key, value): + value = _force_native_endianness_attr(value) if self.format != "NETCDF4": value = encode_nc3_attr_value(value) if _is_list_of_strings(value): @@ -652,7 +662,9 @@ def prepare_variable( self, name, variable: Variable, check_encoding=False, unlimited_dims=None ): _ensure_no_forward_slash_in_name(name) - attrs = variable.attrs.copy() + attrs = { + k: _force_native_endianness_attr(v) for k, v in variable.attrs.items() + } fill_value = attrs.pop("_FillValue", None) datatype: np.dtype | ncEnumType | h5EnumType datatype = _get_datatype( diff --git a/xarray/tests/test_backends.py b/xarray/tests/test_backends.py index 6773c79b003..51a6154b606 100644 --- a/xarray/tests/test_backends.py +++ b/xarray/tests/test_backends.py @@ -7252,6 +7252,31 @@ def test_load_single_value_h5netcdf(tmp_path: Path) -> None: ds2["test"][0].load() +@requires_netCDF4 +def test_roundtrip_non_native_endian_attrs(tmp_path: Path) -> None: + """Test that non-native-endian numeric attribute values round-trip. + + The scipy backend returns attribute values of netCDF-3 files as big-endian + arrays; netCDF4-python does not byte-swap attribute arrays on write, so + writing them back with the netCDF4 backend silently stored byte-swapped + values (e.g. valid_range [0.0, 1.0] became [0.0, 3.03865e-319]). + """ + ds = xr.Dataset( + { + "x": xr.DataArray( + [1.0], + dims=("d",), + attrs={"valid_range": np.array([0.0, 1.0], dtype=">f8")}, + ) + }, + attrs={"levels": np.array([1, 2], dtype=">i4")}, + ) + ds.to_netcdf(tmp_path / "test.nc", engine="netcdf4") + with xr.open_dataset(tmp_path / "test.nc", engine="netcdf4") as ds2: + assert_array_equal(ds2["x"].attrs["valid_range"], [0.0, 1.0]) + assert_array_equal(ds2.attrs["levels"], [1, 2]) + + @requires_zarr @requires_dask @pytest.mark.parametrize( From 238aeff7dd74c2f512afdaa135fe1fb49438b0b8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:19:36 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- xarray/backends/netCDF4_.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/xarray/backends/netCDF4_.py b/xarray/backends/netCDF4_.py index 6e04fce3470..ff9a29a0707 100644 --- a/xarray/backends/netCDF4_.py +++ b/xarray/backends/netCDF4_.py @@ -662,9 +662,7 @@ def prepare_variable( self, name, variable: Variable, check_encoding=False, unlimited_dims=None ): _ensure_no_forward_slash_in_name(name) - attrs = { - k: _force_native_endianness_attr(v) for k, v in variable.attrs.items() - } + attrs = {k: _force_native_endianness_attr(v) for k, v in variable.attrs.items()} fill_value = attrs.pop("_FillValue", None) datatype: np.dtype | ncEnumType | h5EnumType datatype = _get_datatype( From 121c0595c427dfdf731aafd5a055abb77a60ebae Mon Sep 17 00:00:00 2001 From: glaziermag Date: Mon, 24 Aug 2026 14:19:39 -0700 Subject: [PATCH 3/3] Add whats-new entry for #11543 Co-Authored-By: Claude Fable 5 --- doc/whats-new.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index d1505bfa081..cab451196ed 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -53,6 +53,10 @@ Deprecations Bug Fixes ~~~~~~~~~ +- Fix the ``netcdf4`` engine silently writing byte-swapped values for + non-native-endian numeric attribute arrays, such as attributes of netCDF-3 + files read with the ``scipy`` engine (:pull:`11543`). + By `glaziermag `_. - Fix async zarr tests using ``wraps`` with ``autospec=True`` on async methods, which caused ``AsyncMock`` objects to leak through instead of real array data (:pull:`11232`).