xarray vs Rasterio for Time-Series Rasters
Once a directory holds 120 scenes of the same place, the question stops being "how do I read a GeoTIFF" and becomes "who keeps track of which file is which date". This guide is for analysts and data engineers deciding whether a labelled cube is worth its overhead for stacked raster work; it sits under xarray & rioxarray Raster Cubes in Mastering Core Geospatial Python Libraries, and assumes you already know how to pull bands out of a single file the way reading multi-band TIFFs with Rasterio describes. The worked example throughout is one job — a monthly mean NDVI composite from 120 Sentinel-2 derived scenes — written both ways.
Why This Approach / What Goes Wrong
Rasterio's model stops at the edge of a file. A dataset object gives you bands, a transform, a CRS and windows into that raster, and it does those things with almost no abstraction between your code and GDAL. Nothing in the API spans files, so the moment your analysis crosses scenes you become the index: parsing dates out of filenames, grouping them, allocating accumulators, dividing sums by counts, and remembering that axis 0 of your stack means time this week and band last week. That bookkeeping is not hard, it is just unpaid and untested, and it lives in every script that touches the archive.
Two concrete failures follow. The first is memory. The obvious loop reads each scene fully and stacks the result — but a 10 m Sentinel-2 tile is 10980 × 10980 pixels, 482 MB as float32, so 120 of them is 57.8 GB and np.stack dies on any normal machine. The fix (accumulate over blocks instead of materializing) is correct and cheap, but it is another thirty lines of hand-written state.
The second failure is quieter and worse. A NumPy array from src.read() carries no coordinates, so if one scene is a half-pixel off, comes from a neighbouring tile, or was resampled to 20 m and reprojected, it stacks perfectly well and the composite is silently wrong. xarray cannot make that data good, but it makes the problem loud: arrays are aligned on coordinate labels, so mismatched grids either raise under join="exact" or produce visible NaN instead of plausible nonsense. That guarantee — not raw speed — is the main thing the cube sells. Rasterio is not the loser here either: it remains the better tool for single-scene I/O, for byte-level block control, and for writing anything you care about the encoding of, which is why the two-library comparison in Rasterio vs GDAL Python bindings still applies underneath the cube — rioxarray calls rasterio for every byte it moves.
Prerequisites
xarray>=2024.7— the labelled array model,concat,resample,polyfitrioxarray>=0.15— the.rioaccessor and therasteriobackend engine foropen_datasetrasterio>=1.3.9— the I/O layer both paths ultimately usedask[array]>=2024.7— chunked, out-of-core execution for the cubenumpy>=1.26andpandas>=2.2— array maths and theDatetimeIndexbehind the time coordinate
python -m pip install "xarray>=2024.7" "rioxarray>=0.15" "rasterio>=1.3.9" \
"dask[array]>=2024.7" "numpy>=1.26" "pandas>=2.2"
Install the whole set from one package manager. rioxarray and rasterio bind the same GDAL and PROJ builds, and a pip rasterio wheel dropped on top of a conda GDAL is the usual cause of an import that fails only on the deployment host.
Step-by-Step Implementation
1. The rasterio version: group by month, accumulate over blocks. This is the memory-safe form, not the naive one — nothing bigger than one block per scene is ever resident, and the grid check is explicit because nothing else will do it for you.
import re
from collections import defaultdict
from contextlib import ExitStack
from pathlib import Path
import numpy as np
import rasterio
scene_paths = sorted(Path("s2_ndvi").glob("ndvi_*.tif")) # ndvi_20230612.tif, ...
by_month: dict[str, list[Path]] = defaultdict(list)
for path in scene_paths:
stamp = re.search(r"(\d{8})", path.stem).group(1)
by_month[stamp[:6]].append(path) # '202306' -> [Path, ...]
with rasterio.open(scene_paths[0]) as reference:
ref_transform, ref_crs = reference.transform, reference.crs
ref_shape = (reference.height, reference.width)
blocks = [window for _, window in reference.block_windows(1)]
monthly_means: dict[str, np.ndarray] = {}
for month, paths in sorted(by_month.items()):
composite = np.full(ref_shape, np.nan, dtype="float32")
with ExitStack() as stack:
sources = [stack.enter_context(rasterio.open(p)) for p in paths]
for src in sources: # nothing else checks this for you
if src.transform != ref_transform or src.crs != ref_crs:
raise ValueError(f"{src.name} is not on the reference grid")
for window in blocks:
total = count = None
for src in sources:
tile = src.read(1, window=window, masked=True)
valid = ~np.ma.getmaskarray(tile)
if total is None:
total = np.zeros(tile.shape, dtype="float64")
count = np.zeros(tile.shape, dtype="uint16")
total += np.where(valid, tile.filled(0.0), 0.0)
count += valid
row_off, col_off = int(window.row_off), int(window.col_off)
rows, cols = total.shape
composite[row_off:row_off + rows, col_off:col_off + cols] = np.divide(
total, count, out=np.full(total.shape, np.nan), where=count > 0
).astype("float32")
monthly_means[month] = composite
Every line is doing something necessary, and none of it is about NDVI. masked=True is what keeps the file's nodata value out of the sum; count is what makes the divisor per-pixel rather than per-month; ExitStack keeps ten dataset handles open across the block loop instead of reopening each file thousands of times.
2. The xarray version: let the filename become a coordinate, then reduce. xr.open_mfdataset opens all 120 files lazily through the same rasterio backend and calls preprocess on each one, which is where the date turns into a real time coordinate.
import re
from pathlib import Path
import pandas as pd
import rioxarray # noqa: F401 — registers the .rio accessor
import xarray as xr
def stamp_time(ds: xr.Dataset) -> xr.Dataset:
"""Promote the acquisition date in the filename to a length-1 time dimension."""
stem = Path(ds.encoding["source"]).stem
date = pd.to_datetime(re.search(r"\d{8}", stem).group(), format="%Y%m%d")
return ds.squeeze("band", drop=True).expand_dims(time=[date])
cube = xr.open_mfdataset(
sorted(Path("s2_ndvi").glob("ndvi_*.tif")),
engine="rasterio",
preprocess=stamp_time,
combine="nested",
concat_dim="time",
join="exact", # raise on any grid that does not match
chunks={"x": 2048, "y": 2048},
mask_and_scale=True, # nodata -> NaN, as masked=True does above
)["band_data"].rename("ndvi")
monthly = cube.resample(time="MS").mean() # skipna=True by default
Those two statements replace the whole accumulator loop, and they replace it with stronger behaviour: join="exact" fails on a mismatched grid, mask_and_scale=True handles nodata, and .mean() skips NaN per pixel, so the divisor is already per-pixel. The reduction is still lazy — monthly is a task graph over 120 files, and nothing has been read.
3. Understand where the memory actually goes. The cube is not magic; it is bounded because Dask never holds more than the chunks a task needs. With {"x": 2048, "y": 2048} and one time step per file, a chunk is 16 MB, and a monthly mean gathers the ten or so chunks covering that month — roughly 170 MB — before reducing. Peak resident memory is about that figure times the number of worker threads, plus the output. The hand-written block loop reaches the same bound by a different route, and the naive np.stack version reaches 57.8 GB and dies.
4. Handle grids that do not match, instead of asserting they do. The rasterio loop above raises on the first offending scene and stops, which is honest but unhelpful when a supplier delivered three tiles at 20 m. On the cube side, reproject_match warps each incoming scene onto a reference grid during preprocess, so the concat stays exact and the misfits are repaired rather than rejected.
import rioxarray
from rasterio.enums import Resampling
grid = rioxarray.open_rasterio(scene_paths[0], chunks=True).squeeze("band", drop=True)
def stamp_and_align(ds: xr.Dataset) -> xr.Dataset:
ds = stamp_time(ds)
if ds.rio.transform() != grid.rio.transform() or ds.rio.crs != grid.rio.crs:
# bilinear for continuous NDVI; nearest for anything categorical
ds = ds.rio.reproject_match(grid, resampling=Resampling.bilinear)
return ds
Pass preprocess=stamp_and_align instead of stamp_time and the cube absorbs mixed resolutions without losing the join="exact" guarantee. Pick the reference grid deliberately — the finest scene if you are willing to interpolate, the coarsest if you refuse to invent detail — and keep the analysis CRS metric (a UTM zone or an equal-area projection, never Web Mercator, whose scale factor makes per-pixel area a function of latitude). The full grid-matching rules live in reprojecting raster cubes with reproject_match.
5. Write the results with rasterio. This is the hybrid that production pipelines converge on: the cube expresses the analysis, rasterio writes the output, because to_raster deliberately exposes only a slice of GDAL's creation options and needs a lock under threads.
import numpy as np
import rasterio
from rasterio.enums import Resampling
with rasterio.open(scene_paths[0]) as reference:
profile = reference.profile.copy()
profile.update(
driver="GTiff", count=1, dtype="float32", nodata=np.nan,
tiled=True, blockxsize=512, blockysize=512,
compress="DEFLATE", predictor=3, # predictor 3 = floating point
BIGTIFF="IF_SAFER",
)
for stamp in monthly["time"].values:
layer = monthly.sel(time=stamp).compute() # one month resident at a time
name = np.datetime_as_string(stamp, unit="M").replace("-", "")
with rasterio.open(f"ndvi_mean_{name}.tif", "w", **profile) as dst:
dst.write(layer.values.astype("float32"), 1)
dst.build_overviews([2, 4, 8, 16], Resampling.average)
dst.update_tags(ns="rio_overview", resampling="average")
y and x vary, do the grids agree, and does the stack fit in memory.Verification
The two paths must agree pixel for pixel on any month, and disagreement is the only reliable signal that one of them has a bookkeeping bug. Compare them where both are finite, because the cube writes NaN wherever every scene was cloud-masked and the loop writes NaN wherever count == 0 — the same pixels, by construction.
import numpy as np
xr_july = monthly.sel(time="2023-07-01").values.astype("float32")
loop_july = monthly_means["202307"]
assert xr_july.shape == loop_july.shape, "grids diverged — check join='exact'"
# NaN must appear in exactly the same places, then values must match
np.testing.assert_array_equal(np.isnan(xr_july), np.isnan(loop_july))
both = np.isfinite(xr_july)
np.testing.assert_allclose(xr_july[both], loop_july[both], rtol=1e-5, atol=1e-6)
print(f"July 2023: {both.sum():,} valid px, "
f"max |diff| {np.nanmax(np.abs(xr_july - loop_july)):.2e}")
# July 2023: 118,452,301 valid px, max |diff| 4.77e-07
The residual difference is float32 accumulation order, not error: the loop sums in float64 and casts once, Dask sums per chunk in the array's own dtype. If you need bit-identical output, add .astype("float64") to the cube before reducing.
Edge Cases & Debugging
- The composite is entirely NaN. Coordinate labels do not line up, so the reduction had nothing to average. Print
cube.rio.transform()against a single scene's transform; if they differ at all, route the scenes throughreproject_matchas in step 4. ValueError: cannot align objects with join='exact'. Working as intended — one file is on a different grid. Switch to thestamp_and_alignpreprocess, or drop the offender; never "fix" it by relaxing tojoin="outer", which pads with NaN and hides the problem.OSError: [Errno 24] Too many open filesin the rasterio loop.ExitStackholds one handle per scene in the month; with hundreds of scenes per group, raise the limit or restructure to open one file at a time per window.FutureWarningonresample(time="M"). The period-end alias is deprecated in pandas 2.2; use"ME"for month-end or"MS"for month-start, and prefer"MS"so the timestamps label the month they summarize.- The lazy cube blows up at
.compute(). A per-pixel reduction over time withchunks={"time": -1}holds the whole series per spatial chunk — shrinkx/yto 1024 instead. Conversely, per-scene work wantstime=1with large spatial chunks. - Opening 120 remote COGs takes minutes. Each open is a header request. Set
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIRand passparallel=Truetoopen_mfdatasetso the headers are fetched concurrently.
Frequently Asked Questions
How many scenes before the cube pays for itself? Roughly three, if the analysis actually crosses them. The break-even is not about volume but about whether you would otherwise be writing index bookkeeping — the moment your code parses a date from a filename or allocates an accumulator, the cube already costs less to maintain. For one scene in and one scene out, rasterio wins on every axis including startup time.
Is xarray slower than a plain rasterio loop? Per byte, no — rioxarray calls rasterio underneath, so the read path is identical. Per call, yes: building a Dask graph over 120 files costs a few hundred milliseconds and each task carries scheduler overhead, which is pure loss on a single small scene. On stacked work the cube usually wins anyway, because it reads only the chunks a reduction touches while a naive loop reads whole scenes.
Can I mix the two in one pipeline?
That is the normal production shape. Express the analysis on the cube, then hand the materialized result to rasterio for the write, exactly as step 5 does — you get labelled, lazily evaluated maths plus full control over compression, block size, predictor and overviews. Going the other way, rioxarray.open_rasterio accepts any GDAL path including a /vsicurl/ URL or a VRT built by GDAL.
Does the cube really read only what it needs?
Yes, when the chunks are aligned to the file's internal blocks. open_rasterio(..., chunks=True) adopts the file's own block layout; an explicit dict should stay a multiple of 512 for typical COGs, otherwise a single task straddles blocks and each block is fetched twice. A .sel() on time or space prunes whole chunks out of the graph before any request is made.