xarray & rioxarray: Labelled Raster Cubes in Python

A GeoTIFF is a rectangle of numbers with a corner coordinate and a pixel size. That model is enough for a single scene, and Raster Data Handling with Rasterio covers it thoroughly. It stops being enough the moment you have forty scenes of the same place, or a climate file with pressure levels and forecast hours, or a Sentinel-2 stack where band, date and tile all vary independently — because then every array index means something, and tracking what it means becomes your problem. xarray solves that by attaching labels to every axis; rioxarray adds the geospatial half back, giving those labelled arrays a CRS, an affine transform, a nodata value, and reprojection. This part of Mastering Core Geospatial Python Libraries covers the combined model end to end: how a cube is structured, how to open one lazily, how to select by coordinate label instead of pixel index, and how to get results back out as a COG, a NetCDF file, or a Zarr store.

Anatomy of an xarray raster cube A stack of four raster layers represents a DataArray. Arrows label the three axes: x is easting in metres along the bottom, y is northing along the left side, and time runs into the depth of the stack. Panels on the right list the parts that make it a cube rather than a bare array: dims are time, y and x; coords give the actual timestamps, northings, eastings, and a non-dimension spatial_ref coordinate holding EPSG 32633; attrs carry long_name, units and scale_factor. A closing note says the rio accessor reads spatial_ref to produce a CRS and an affine transform. A raster cube: pixel values plus a label for every axis NDVI values float32 · 1200 × 1200 nodata → NaN x — easting, metres y — northing metres time a fourth dim, band, stacks the same way dims ("time", "y", "x") coords time 2023-06-01 ... 2023-09-01 y 5641000 ... 5629000 m x 412000 ... 424000 m spatial_ref EPSG:32633 (non-dim) attrs long_name: "NDVI" units: "1" grid_mapping: "spatial_ref" .rio reads spatial_ref → CRS + affine transform
The array holds the numbers; dims, coords and attrs hold everything needed to interpret them — and spatial_ref is where rioxarray stores the CRS.

Architecture & Data Structures

xarray has exactly two containers. A DataArray is one N-dimensional NumPy (or Dask) array plus four pieces of metadata: dims, a tuple of axis names; coords, the actual label values along each axis; attrs, a free-form metadata dictionary; and name. A Dataset is a dict-like collection of DataArray variables that share a coordinate system — the natural fit for a NetCDF file holding temperature, precipitation and wind on one grid. Everything else in the library is built on those two.

The labels are not decoration. They drive alignment: when you subtract two DataArray objects, xarray matches them on coordinate values, not positions, and any label present in one but not the other becomes NaN. That behaviour is the source of both xarray's biggest convenience and its most confusing failure mode, and both are covered below.

rioxarray adds a .rio accessor to both containers. Importing the package is what registers it — you rarely call rioxarray by name after the import line. The accessor reads and writes a non-dimension coordinate named spatial_ref, which follows the CF convention for grid mappings, and derives everything geospatial from it plus the x/y coordinate vectors.

import rioxarray  # registers the .rio accessor on xarray objects
import xarray as xr

# A single scene: dims (band, y, x) with x/y in the file's own CRS units
scene = rioxarray.open_rasterio("ortho_2023_07.tif", masked=True, chunks=True)

print(scene.dims)             # ('band', 'y', 'x')
print(scene.coords["band"].values)   # [1 2 3 4] — band labels start at 1
print(scene.rio.crs)          # EPSG:32633
print(scene.rio.transform())  # Affine(10.0, 0.0, 412000.0, 0.0, -10.0, 5641000.0)
print(scene.rio.resolution()) # (10.0, -10.0)
print(scene.rio.nodata)       # None once masked=True has converted it to NaN

Three details in that snippet matter in practice. masked=True promotes the array to a float dtype and turns the file's nodata value into NaN, which is what you want before any arithmetic — an unmasked -9999 silently poisons a mean. chunks=True makes the array Dask-backed using the file's own internal block layout, so nothing is read yet. And the band coordinate is 1-based because GDAL bands are 1-based; scene.sel(band=1) and scene.isel(band=0) select the same layer, which trips people up exactly once.

There is a second entry point. xr.open_dataset(path, engine="rasterio") routes through the same rioxarray backend but returns a Dataset with a single variable called band_data, which is useful when you want a uniform Dataset interface across mixed GeoTIFF and NetCDF inputs. Passing band_as_variable=True to open_rasterio goes further and splits each band into its own named variable — handy when the bands are semantically different quantities rather than slices of one quantity.

Environment Configuration & Dependency Resolution

The stack is xarray for the labelled model, rioxarray for the geospatial accessor, rasterio (and therefore GDAL) for I/O, and Dask for out-of-core execution. rioxarray depends on rasterio and pyproj, so the C-library alignment rules from those pages apply unchanged: one package manager for the whole environment, never a pip rasterio wheel on top of a conda GDAL.

python -m pip install \
  "xarray>=2024.7" "rioxarray>=0.15" "rasterio>=1.3.9" "pyproj>=3.6" \
  "dask[array]>=2024.7" "netCDF4>=1.6" "zarr>=2.17" "bottleneck>=1.3"

netCDF4 and zarr are optional — install them only for the formats you actually read or write. bottleneck is worth having anyway: xarray dispatches rolling and NaN-aware reductions to it and gets a large speedup for free. For NetCDF specifically there are two engines, netcdf4 and h5netcdf; the latter is a pure-Python HDF5 reader that avoids the netCDF-C library entirely and is often the easier install on Windows.

import xarray as xr

# Run this in CI — prints xarray, pandas, dask, rasterio, netCDF4 and GDAL versions
xr.show_versions()

One environment variable is worth setting for cloud reads. GDAL lists the containing directory when it opens a file, which on object storage costs an extra request per open; GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR skips it. Combined with /vsicurl/ or /vsis3/ paths, open_rasterio reads remote COGs by range request exactly as described in Cloud-Native Geospatial Formats.

Vectorized Operations & Core Workflow

The canonical workflow is: open every scene lazily, concatenate them along a new time dimension, express the analysis as whole-cube operations, and trigger computation exactly once at the end. Nothing between the open and the trigger touches a pixel.

from pathlib import Path

import pandas as pd
import rioxarray
import xarray as xr

# One COG per acquisition date: ndvi_20230612.tif, ndvi_20230627.tif, ...
scene_paths = sorted(Path("s2_ndvi").glob("ndvi_*.tif"))
dates = pd.to_datetime([p.stem.split("_")[-1] for p in scene_paths], format="%Y%m%d")

layers = [
    rioxarray.open_rasterio(p, masked=True, chunks={"x": 1024, "y": 1024})
    .squeeze("band", drop=True)          # single-band scenes: drop the length-1 axis
    for p in scene_paths
]

# The new axis gets real timestamps as its coordinate, not 0..n
cube = xr.concat(layers, dim=pd.Index(dates, name="time"))
cube.name = "ndvi"
cube = cube.rio.write_crs(layers[0].rio.crs)

print(cube.dims)    # ('time', 'y', 'x')
print(cube.nbytes / 1e9, "GB if fully materialized")

xr.concat with a pd.Index as the dim argument is the idiom worth memorising: it creates the dimension and its coordinate in one step, so the resulting axis is a real DatetimeIndex that supports partial-string selection and resampling. Concatenating with dim="time" alone would produce an unlabelled axis and throw away the dates.

From there the analysis reads like NumPy with names instead of positions:

# Growing season only — a label slice, inclusive of both endpoints
summer = cube.sel(time=slice("2023-06-01", "2023-08-31"))

# Drop cloud-flagged pixels, then reduce to a monthly composite
clear = summer.where(summer > -0.2)
monthly = clear.resample(time="MS").mean()      # MS = month start

# Peak-of-season NDVI: still lazy, still nothing read
peak = monthly.max(dim="time")

# One trigger, one pass over the data
peak = peak.compute()

Because the reductions name their axis (dim="time"), the code stays correct if you later add a band dimension or reorder the axes. A positional axis=0 in the equivalent rasterio loop would not.

Lazy execution from open_rasterio to the trigger A left-to-right flow. Source rasters on disk or object storage feed rioxarray open_rasterio with chunks set to True, which reads only metadata such as shape, dtype and CRS. The result is a Dask-backed DataArray drawn as a grid of eight chunk tiles, one task per file block. Lazy operations like sel, where and resample mean extend the graph without reading data. A trigger arrow leads down to compute or rio to_raster, which produces a materialized result, either a NumPy array in memory or a COG or Zarr store on disk. A side panel notes that the graph stores only shape, dtype and a chunk map, that sel and where prune chunks before any input or output, and that peak memory is roughly one chunk per worker thread. Lazy by default: the graph is built long before any byte is read Source rasters COG · NetCDF · Zarr local or object store rioxarray.open_rasterio chunks=True reads metadata only shape · dtype · CRS Dask-backed DataArray one task per file block lazy operations .sel(time=slice(...)) .where(mask) .resample('MS').mean() trigger compute the graph .compute() / .load() .rio.to_raster(...) Materialized result NumPy array in RAM or COG / Zarr on disk Nothing reads pixels until the trigger · the graph stores shape, dtype and a chunk map · .sel and .where prune chunks before any I/O · peak RAM ≈ one chunk per worker thread open → chunk → build the graph → trigger exactly once
Every step up to .compute() only extends a task graph; sizing chunks well is what keeps that final pass inside memory.

Chunk sizing is the one tuning knob that matters. Aim for chunks of roughly 50–200 MB and make the x/y chunk a whole multiple of the file's internal block size (512 for most COGs) so a single task never reads a partial block twice. Chunking time=1 suits per-scene work such as cloud masking; chunking time=-1 (the whole axis in one chunk) suits per-pixel time-series reductions, because each task then holds a complete series. Getting this backwards is the usual cause of a job that spends its life shuffling.

Raster Cube Processing Details

Label-based selection is the payoff for carrying coordinates around. .sel() indexes by coordinate value; .isel() indexes by integer position. Mixing them up is harmless with time, where a string cannot be an integer, and dangerous with x/y, where both are numbers.

july = cube.sel(time="2023-07")                  # partial-string datetime match
first_scene = cube.isel(time=0)                  # positional
nearest = cube.sel(time="2023-07-15", method="nearest")   # closest acquisition

# Spatial window in projected CRS units. NOTE the y slice runs high -> low,
# because north-up rasters have a DESCENDING y coordinate.
window = cube.sel(x=slice(414_000, 418_000), y=slice(5_639_000, 5_635_000))

That descending y is the single most common .sel bug on raster cubes: a slice written low-to-high silently returns a zero-length array rather than raising. Assert on window.sizes["y"] > 0 in any pipeline that builds slices from computed bounds.

Point sampling is where the labelled model produces genuinely less code than the alternative. Wrap the coordinates in DataArray objects that share a new dimension, and xarray does pointwise (not outer-product) selection:

import geopandas as gpd
import xarray as xr

sensors = gpd.read_file("sensors.gpkg").to_crs(cube.rio.crs)
site_ids = sensors["site_id"].to_numpy()

xs = xr.DataArray(sensors.geometry.x.to_numpy(), dims="site", coords={"site": site_ids})
ys = xr.DataArray(sensors.geometry.y.to_numpy(), dims="site", coords={"site": site_ids})

# Result dims: (time, site) — one full time series per sensor, in one read
series = cube.sel(x=xs, y=ys, method="nearest")
tidy = series.to_dataframe().reset_index()   # long-format DataFrame, ready for pandas

Temporal aggregation uses the same vocabulary as pandas because xarray borrows it. resample regrids the time axis onto a new frequency; groupby with a datetime component builds a climatology; rolling gives a moving window that keeps the original axis.

monthly = cube.resample(time="MS").mean()             # calendar-month composites
climatology = cube.groupby("time.month").mean("time") # mean per month-of-year
anomaly = cube.groupby("time.month") - climatology    # broadcast back against it
smoothed = cube.rolling(time=3, center=True).mean()   # 3-scene moving average

Note the pandas frequency aliases: "MS" is month-start and "YS" is year-start, both stable, whereas the old period-end alias "M" is deprecated in favour of "ME". Use the start-anchored ones and the code survives the pandas upgrade.

Reading NetCDF is the other half of the story, and the shape of the data changes: a Dataset with several variables, coordinates usually named lon/lat or longitude/latitude, and no spatial_ref at all. rioxarray needs the spatial dims named x and y (or declared explicitly) before the accessor works — the full walkthrough is in reading NetCDF climate data with xarray.

import xarray as xr
import rioxarray  # noqa: F401 — needed for .rio

ds = xr.open_dataset("era5_t2m_2023.nc", chunks={"time": 24}, engine="netcdf4")
print(list(ds.data_vars))       # ['t2m', 'tp']

t2m = (
    ds["t2m"]
    .rename({"longitude": "x", "latitude": "y"})
    .rio.set_spatial_dims(x_dim="x", y_dim="y")
    .rio.write_crs("EPSG:4326")   # ERA5 is on a plain geographic grid
)

For many files on one grid, xr.open_mfdataset(paths, combine="by_coords", parallel=True) opens them as one lazy cube without a manual concat — the right tool when the time axis is already encoded inside each file.

xarray versus rasterio versus GeoPandas by data shape A five-row comparison of three containers. Native shape: xarray holds an N-dimensional array with time, band, y and x; rasterio holds two-dimensional bands in one file; GeoPandas holds rows of geometries. Indexing: by coordinate label, by pixel window, and by attribute or geometry respectively. Time: a real dimension, one file per date, or just another column. Memory model: lazy Dask chunks, eager NumPy windows, whole frame in RAM. Best fit: stacks and cubes over time, single-scene input and output, and vector features with attributes. Three containers, three data shapes Selection axis xarray + rioxarray labelled N-D cube rasterio flat NumPy windows GeoPandas geometry table Native shape N-D labelled array (time, band, y, x) 2-D bands in one file (band, row, col) rows of geometries one shape per record How you index .sel(time="2023-07") by coordinate label Window(col, row, w, h) by pixel index .loc / .cx / sjoin by attribute or geometry Time a real dimension resample, rolling, groupby one file per date you loop and stack just another column pandas handles it Memory model lazy Dask chunks graph until .compute() eager NumPy windows you size every read whole frame in RAM Dask-GeoPandas to scale Reach for it when stacks, cubes, climate same grid over time one scene in, one out COG writes, tight control features + attributes joins, overlays, buffers
Pick the container by the shape of the data, not by habit — a cube earns its overhead only when an axis other than y and x actually varies.

The head-to-head for the raster case specifically — including where a rasterio loop still wins — is worked through in xarray vs rasterio for time-series rasters.

CRS Alignment & Projection Pipeline

rioxarray stores the CRS in the spatial_ref coordinate as CF-compliant WKT, and every data variable carries a grid_mapping attribute pointing at it. That is why .rio.write_crs() returns a new object rather than mutating in place — it is adding a coordinate, not setting a field. Use inplace=True only when you deliberately want mutation.

from rasterio.enums import Resampling

# Declare a KNOWN source CRS on a cube that arrived without one (e.g. from NetCDF)
t2m = t2m.rio.write_crs("EPSG:4326")

# Pick a metric CRS from the data's own extent rather than hard-coding a zone
utm = cube.rio.estimate_utm_crs()          # e.g. EPSG:32633
cube_utm = cube.rio.reproject(utm, resampling=Resampling.bilinear)

print(cube_utm.rio.crs.to_epsg())          # 32633
print(cube_utm.rio.resolution())           # (10.0, -10.0)

Choose the resampling kernel by data semantics, exactly as in rasterio: bilinear or cubic for continuous surfaces such as reflectance or elevation, nearest for categorical rasters so class codes are never averaged into values that mean nothing. And never reproject to EPSG:3857 for measurement — its scale factor grows with latitude, so any area or distance computed there is wrong. Reproject to a UTM zone or an equal-area CRS for analysis and to Web Mercator only at the tile-render boundary. Axis order is not a concern inside rioxarray (it always works in x, y order), but the moment you build a pyproj.Transformer yourself to overlay points, pass always_xy=True.

Alignment between two cubes is a stricter requirement than a shared CRS. Because xarray aligns on coordinate labels, two arrays on the same CRS but with a half-pixel offset or a different resolution have no labels in common, and dem - landcover returns an array that is entirely NaN — no error, no warning. .rio.reproject_match() fixes this by warping one array onto the other's exact CRS, transform and shape in a single call.

import rioxarray
from rasterio.enums import Resampling

dem = rioxarray.open_rasterio("dem_10m.tif", masked=True, chunks=True).squeeze("band", drop=True)
landcover = rioxarray.open_rasterio("corine_100m.tif", chunks=True).squeeze("band", drop=True)

# Warp landcover onto the DEM's grid: same CRS, transform, width and height
landcover_on_dem = landcover.rio.reproject_match(dem, resampling=Resampling.nearest)

assert landcover_on_dem.rio.crs == dem.rio.crs
assert landcover_on_dem.shape == dem.shape
assert landcover_on_dem.rio.transform() == dem.rio.transform()

steep_forest = dem.where(landcover_on_dem == 23)   # now the arithmetic is meaningful
Before and after reproject_match Two panels. On the left, before alignment, a fine 10 metre elevation grid in EPSG 25832 sits beside a coarse 30 metre landcover grid in EPSG 4326; subtracting them yields an all-NaN array because no coordinate labels line up. On the right, after calling rio reproject_match against the elevation array, both grids are 10 metre and in EPSG 25832, and the subtraction returns an aligned array sharing the same dims, coords and transform. Elementwise math needs identical coordinates Before — two grids, two CRSs + elevation · 10 m EPSG:25832 landcover · 30 m EPSG:4326 dem - landcover → all NaN: no labels line up After .rio.reproject_match(dem) + elevation · 10 m EPSG:25832 landcover · 10 m EPSG:25832 dem - landcover → same dims, coords, transform
An all-NaN result is almost always misalignment, not bad data — reproject_match is the one-call fix, and the failure mode is silent without it.

The grid-alignment rules, including how to choose the match target and what happens to nodata at the edges, are laid out in reprojecting raster cubes with reproject_match. For clipping a cube to a vector boundary — where the geometry CRS must be stated explicitly rather than assumed — see clipping rasters by vector geometry with rioxarray.

Production Export & Integration

Three output formats cover almost every downstream need, and the choice follows the shape of what you are exporting. A single 2-D result goes to a COG. A multi-variable cube that has to be readable by scientific tooling goes to NetCDF. A cube that will be read repeatedly, in slices, from object storage goes to Zarr.

# 1. Single 2-D layer -> Cloud Optimized GeoTIFF
peak = monthly.max(dim="time").rio.write_nodata(float("nan"), encoded=True)
peak.rio.to_raster(
    "ndvi_peak_2023.tif",
    driver="COG",          # GDAL 3.1+ writes tiling and overviews for you
    compress="DEFLATE",
    predictor=2,           # good for continuous float/int data
)

# 2. Multi-variable cube -> NetCDF with compression and int packing
cube_ds = cube.to_dataset()
cube_ds = cube_ds.rio.write_crs(cube.rio.crs)   # writes the CF spatial_ref variable
cube_ds.to_netcdf(
    "ndvi_cube_2023.nc",
    engine="netcdf4",
    encoding={"ndvi": {"zlib": True, "complevel": 4, "dtype": "int16",
                       "scale_factor": 0.0001, "_FillValue": -32768}},
)

# 3. Chunked cube -> Zarr for repeated partial reads from object storage
cube_ds.chunk({"time": 4, "y": 1024, "x": 1024}).to_zarr(
    "ndvi_cube_2023.zarr", mode="w", consolidated=True
)

Writing a large Dask-backed array to GeoTIFF deserves care: to_raster(..., windowed=True) streams block by block instead of materializing the whole array, and when several Dask threads write concurrently you need lock=threading.Lock() (or lock=True) so GDAL is not called re-entrantly on one dataset handle. Skipping the lock produces corrupt output rather than an exception.

Deployment checklist:

Downstream, the same cube feeds the rest of the stack cleanly. Zonal summaries per polygon go through zonal statistics and raster sampling; a COG written here is directly consumable by a tile server, and the storage-layer trade-offs sit in Cloud-Native Geospatial Formats.

Windows / Platform Edge Cases & Debugging

Most failures here fall into two families: an HDF5 or GDAL library that does not match its Python wrapper, and a silent alignment problem that produces NaN instead of an error.

Frequently Asked Questions

When is a cube actually better than a loop over rasterio? When any axis other than y and x varies and you need to operate across it. Compositing forty dates, computing a per-pixel trend, taking a monthly climatology, or masking one stack by another are all one-liners on a cube and forty-iteration loops with manual bookkeeping in rasterio. For a single scene in and a single scene out — reproject one file, write one COG — rasterio is leaner and has fewer moving parts.

Does open_rasterio read the whole file into memory? Only if you omit chunks. Without it the array is NumPy-backed and eager, so the data lands in RAM as soon as any value is touched. With chunks=True (or an explicit dict) the array is Dask-backed and the open reads only header metadata — shape, dtype, transform, CRS. Computation happens at .compute(), .load(), or when you write output.

open_rasterio or open_dataset — which should I use? Use rioxarray.open_rasterio for GDAL-readable rasters when you want a DataArray with band/y/x and the CRS already attached. Use xr.open_dataset for NetCDF, HDF5, GRIB and Zarr, where the file holds several named variables on a shared grid; add engine="rasterio" if you want a GeoTIFF presented as a Dataset for interface consistency. Both paths end up with the same .rio accessor.

Why did my CRS disappear after a groupby? Some xarray operations drop non-dimension coordinates, and spatial_ref is one. The fix is to re-attach it — result = result.rio.write_crs(source.rio.crs) — immediately before writing. Treat write_crs as cheap and call it defensively at every stage boundary rather than assuming it survived.

How big should chunks be? Target 50–200 MB per chunk and align the x/y chunk size to a multiple of the file's internal block size, usually 512 for COGs. Chunk time=1 for per-scene work such as cloud masking, and time=-1 for per-pixel time-series reductions so each task owns a whole series. Too-small chunks drown the scheduler in task overhead; too-large chunks exhaust memory when several run in parallel.

Can I write a whole cube to one GeoTIFF? Only if it reduces to at most three dimensions with the extra axis mapped onto bands — cube.rio.to_raster() will write a (time, y, x) array as a multi-band file, but the timestamps become band numbers and the labels are lost. If the time axis matters downstream, write NetCDF or Zarr; use GeoTIFF for the 2-D results you extracted from the cube.