Changelog
0.5.0 - 2026-07-11
Added
open_dataset(..., config_options={...})surfaces GDAL configuration options (#25), e.g.{"GDAL_HTTP_MAX_RETRY": "3"}. Options are applied thread-locally around the open and re-entered around every read, then unset again: per-thread reopens under dask (#34) and reads on distributed workers after unpickling inherit them by construction, and concurrent opens with different options never interfere or leak into unrelated work sharing a thread pool. Values are stringified, the options participate in dask tokenization both on the raw backend arrays and in the managed-chunking layer token (same dsn with different options yields different layer names, so dask never deduplicates across differently-configured opens), and they are not applied during engine auto-detection (guess_can_open).warp()is unchanged; passing options there is a possible follow-up.
Fixed
-
Outer and boolean indexers now work on all read paths, and stepped or reversed slices return correct data. Selections built from integer or boolean arrays, e.g.
isel(time=(ds.time.dt.month == 6) & (ds.time.dt.day == 27)), previously raisedIndexError: Unsupported index type: <class 'numpy.ndarray'>; the three BackendArray classes now declareIndexingSupport.BASICvia xarray'sexplicit_indexing_adapter, which decomposes fancy indexers into a covering basic read plus an in-memory numpy step. At the same time the basic reads themselves were completed: the classic-raster paths previously ignored slice steps entirely (a steppediselreturned the wrong shape or wrong rows), every path read negative-step slices forward without reversing the result, and a reversed band slice (isel(band=slice(None, None, -1))) silently returned an empty array. Classic paths serve stepped/reversed slices by reading the smallest contiguous covering window and reordering in numpy; the multidim path keeps native strided reads and flips negative-step axes after the read; band lists pass arbitrary order natively. Negative integer indices are also supported everywhere, raw int/slice keys on the backend arrays keep working (wrapped as BasicIndexer), and the eager coordinate load at open time goes through the raw read path. (Empty integer-array indexers remain an upstream xarray limitation:_decompose_outer_indexercrashes before backend code runs.) -
The multidim path is now safe under dask's threaded scheduler (#34).
GDALMultiDimArraypreviously read through a single sharedmdarrayhandle from every dask worker thread; GDAL handles are not thread-safe per-handle, so threaded computes over chunked multidim data were a data race (crash or corruption). Reads now resolve a per-thread handle lazily (gdal.OpenEx+OpenMDArrayFromFullnameper worker thread), the same pattern the classic-raster classes already used: GDAL is thread-safe across handles, drivers take their own global locks internally, and osgeo releases the GIL during I/O, so threaded reads are both safe and genuinely parallel. The constructing thread reuses the already-open handle, so the non-dask path pays no reopen cost.__dask_tokenize__no longer touches a live handle (it can be called from any thread), and instances now pickle by dropping handles and reopening on first read, which makes the multidim path usable under distributed schedulers. -
Empty label selections no longer raise or silently return wrong data (#32). pandas emits positional slices with stop < start and a positive step for an empty label selection on a descending coordinate, e.g.
sel(latitude=slice(-16.93, -16.87))on a north-up grid. All three BackendArray classes previously "flipped" such slices into forward reads: when the flipped window fell out of bounds GDAL raisedRuntimeError: arrayStartIdx + (count-1) * arrayStep >= size, and when it stayed in bounds the read silently returned real data for a selection that should be empty. Slices that are empty under Python slice semantics now return correctly shaped zero-size arrays without touching GDAL. (A reverse read is expressed as step < 0, never as stop < start with a positive step.) -
A leading
~in plain filesystem paths is now expanded (#33). GDAL does not perform tilde expansion, so~/file.tifpreviously failed with a misleading "not recognized as a supported file format" error. Expansion applies inopen_dataset, inguess_can_open(so engine auto-detection also works for~paths), and inwarp(). Onlyos.path.expanduseris applied, and only to strings starting with~:abspath/normpathare deliberately avoided because they corrupt GDAL URIs such as/vsicurl/https://...(collapsing the double slash) andZARR:/NETCDF:connection strings. All other dsns pass through byte-identical. -
The
mask_and_scalekeyword is now honoured (#29). It was previously accepted byopen_datasetand silently ignored;mask_and_scale=Falsenow returns raw values with the CF attributes (scale_factor,add_offset,_FillValue) left inattrs, in both classic layouts and multidim mode. -
Unscaled data keeps its native dtype (#29). The
band_as_dim=Falsepath previously attached identityscale_factor=1.0andadd_offset=0.0to every variable (GDAL's Python bindings return 1.0/0.0 for unset scale/offset), which made CF decoding promote plain unscaled Byte/Int16 imagery to float64. Only informative values are recorded now. -
Bands with heterogeneous scale/offset/nodata are surfaced honestly (#29). With
band_as_dim=True, per-band values were previously exposed as coordinates namedscale_factor/add_offset/_FillValue, names CF decoding cannot consume on a per-band coordinate: the dataset looked decoded while the values stayed raw. Such values are now provided asband_scale_factor/band_add_offset/band_nodatacoordinates with aUserWarningpointing atband_as_dim=False, which decodes each band independently. Attributes that are homogeneous across bands still decode (a shared nodata masks even when scales differ).
Breaking for users reading the old per-band coordinate names
scale_factor/add_offset/_FillValue on mixed-band datasets.
- CF decoding (
scale_factor,add_offset,_FillValue) now applies automatically when reading. Previously gdalxarray exposed GDAL's scale/offset/nodata under non-CF attribute names, which prevented xarray's CF decoder from applying value transformation and fill-value masking. The result was raw integer values and sentinel-valued land pixels in scaled scientific data (OISST, CMEMS, ERA5, etc.) — silently wrong. The fix renames attributes to CF-standard names in both classic-raster and multidim modes, makes the backend signature accept xarray's decoder kwargs, and explicitly applies CF decoding before returning the Dataset.
Breaking for users explicitly accessing ds.attrs["scale"],
ds.attrs["offset"], or ds.attrs["nodata"]. These are now
ds.encoding["scale_factor"], ds.encoding["add_offset"], and
ds.encoding["_FillValue"] after decoding (or in ds.attrs[...]
before decoding if mask_and_scale=False is passed).
Changed
-
Python 3.11 or newer is required. The dependency stack has moved past 3.10 (rasterix 0.2.x and current xarray both require >= 3.11), so 3.10 environments could not resolve an install at all; requires-python now states this so pip skips this release cleanly on 3.10.
-
Added silencing for HDF5 messages.
-
Chunking is delegated to xarray's managed path (#31, closes #30). The backend no longer constructs dask collections itself: all variables open as lazy arrays, native GDAL block sizes are recorded in each data variable's
encoding["preferred_chunks"], andchunks=is applied once at the end ofopen_datasetviaDataset.chunk(name_prefix="gdalxarray-", token=<sha256 of the dsn>), after CF decoding and coordinate assembly. The chunked array class is therefore created by the same chunk manager xarray later uses to recognise it, which fixes "Could not find a Chunk Manager which recognises type ..." errors under dask's expression-array migration, and the explicit token means xarray only ever tokenizes strings, so the unpicklable GDAL handles are never pickled or tokenized.
Consequences: dask is now an optional dependency (install with
gdalxarray[dask] or have dask present when passing chunks=); dask
layer names change from gdal-multiband-{path} style to deterministic
gdalxarray-{variable}-{token}; dimensions not named in an explicit
chunks= mapping keep a single chunk (previously band defaulted to
a chunk size of 1; chunks={} still yields per-band chunks via the
recorded native block sizes); and CF decoders now always operate on
lazy arrays rather than dask collections.
[0.4.0] - 2026-06-16
Added
gdalxarray.warp()— build lazy warp-VRT recipes for reprojection, regridding, GCP/RPC/geolocation-array transformation, cutline clipping, and resampling. Returns a VRT XML string that composes with thegdalxarrayengine: only the bytes the consumer reads are materialised. Common args (crs,bbox,shape,resolution,resampling,nodata) plus a full escape hatch for anygdal.WarpOptionskeyword.
Changed
- Classic-raster open (
multidim=False) on a file with no bands but subdatasets now raises aValueErrorlisting the available subdataset paths and pointing atmultidim=True. Previously this produced an empty 512x512 stub Dataset.
0.3.0 - 2026-06-15
Added
band_as_dimparameter onopen_dataset(classic raster mode, defaultTrue). Bands now become an xarraybanddimension on a singleband_dataDataArray. Passband_as_dim=Falsefor per-band-variable layout.- New
GDALMultiBandArrayBackendArray reading viadataset.ReadAsArray(band_list=...), letting GDAL handle BIP/BIL/BSQ interleaving internally. - Lazy-by-default for classic raster mode:
chunks=Nonenow returns aLazilyIndexedArray-wrapped Dataset rather than eagerly reading. ds.encoding["source"]andds.encoding["gdal_driver"]provenance strings (serialization-safe replacements for the live GDAL objects previously stashed in encoding).[tool.ruff]config +.pre-commit-config.yamlfor lint and format.
Changed
gdal.UseExceptions()moved from module import toGDALBackendEntrypoint.__init__, guarded withgdal.GetUseExceptions(). Scopes the side effect to actual backend use.GDALMultiDimArraynow holds optional_parent_datasetand_parent_groupreferences internally to keep the underlyingmdarrayvalid for the dataset's lifetime. Previously stashed inds.encoding, which broketo_netcdf()and other serialization paths.- Per-band metadata (description, nodata, scale, offset) in
band_as_dimmode is attached as scalar attrs when uniform across bands, or asband-dim coordinates when it varies. - Multidim group navigation accepts nested paths (
"/a/b/c") with tolerant leading/trailing slash handling. - Unsupported codecs on individual MDArrays (e.g.
numcodecs.pcodec) are now logged at warning level and the array skipped, instead of aborting the open. This lets you open stores like Earthmover's public ERA5 Icechunk store and access the readable coordinate/mask variables.
Fixed
- usable tokenize method in this frisky world
Dataset.ReadAsArraycall inGDALMultiBandArraynow usesxsize/ysize(the Dataset API) rather thanwin_xsize/win_ysize(which are the Band API).- Reverse-slice canonicalisation across all axes in all three BackendArray
classes - fixes failures when xarray sends
slice(stop, start, 1)for selections on decreasing coordinates (very common in atmospheric data with latitude 90 -> -90). GDALBackendArray.__dask_tokenize__no longer references a non-existentself.datasetattribute. Was latent (only fired on certain Dask graph hashing paths) but would have raised AttributeError when it did.AdviseReadnow skips both tiny reads (no benefit) and huge reads (which blow up on sharded stores reporting shard-sized blocks); boundedCACHE_SIZEbetween 4 MB and 512 MB.
Removed
- Live GDAL objects no longer stored in
ds.encoding. Useds.encoding["source"]andds.encoding["gdal_driver"](strings) for introspection. GDAL refs are now held inside the BackendArrays themselves.
0.2.0 - 2026-05-12
Changed
- Renamed package from
gdxtogdalxarrayfor PyPI publication. The originalgdxname is taken on PyPI by an unrelated GAMS Data Exchange project. - Repository moved from
mdsumner/gdxtohypertidy/gdalxarray. - Python import path is now
from gdalxarray import GDALBackendEntrypoint. - Build backend switched from setuptools to hatchling;
setup.pyremoved. - Packaging modernised to PEP 621 / PEP 639 standards.
requires-pythonbumped to>=3.10to matchxarray>=2025.6.
Added
- CF datetime decoding for time coordinates using
unitsfromMDArray.GetUnit()andcalendarattribute. - Backend arrays accessible via
ds['var'].encoding['gdal_backend']for debugging and introspection. - GDAL dataset and group objects retained in
ds.encoding['gdal_dataset']andds.encoding['gdal_group']to keepMDArraymethods functional. - Entry point registration so
xr.open_dataset(..., engine="gdal")works.
Fixed
- Slice index parsing where
0was incorrectly treated asNonedue to Python's falsy evaluation (k.start or 0->k.start if k.start is not None else 0). - Re-enabled
AdviseReadfor chunk-aligned prefetching on remote datasets.
0.1.0 - 2026-01-20
Initial release as gdx.
Added
- GDAL backend for xarray, supporting both Classic and Multidimensional APIs.
chunks={}uses native block sizes from GDAL'sGetBlockSize(), aligning Dask chunks with storage chunks for efficient reads.multidim=Trueis the default foropen_dataset().
Fixed
- Dask lazy loading for remote Zarr datasets. Zero-sized slice requests (used
by Dask for
_metainference) no longer hang or attempt full array allocation. - Slice start/stop of
0now parsed correctly.