A decade of Landsat 8 imagery over eastern Australia: roughly 12,000 scenes, each a 7.5GB GeoTIFF stack at 30m resolution. Total: approximately 90TB. Loading that into a NumPy array is not an option. Even loading a single band for a single year (1.2TB) would exhaust most workstation RAM.
XArray solves this with lazy evaluation backed by Dask. You can define aggregation operations over the entire dataset — monthly means, annual maxima, decadal trends — and have them execute in chunks that fit in memory, in parallel across CPU cores, without writing intermediate files.
# The Problem: Temporal Raster Stacks
A “temporal raster stack” is a 3D array with dimensions (time, y, x) — or 4D with (time, band, y, x). The time dimension represents acquisition dates; each (y, x) position is a pixel. Common use cases:
- NDVI time series: compute monthly mean vegetation index from daily Landsat/Sentinel scenes
- Sea surface temperature: monthly climatologies from decades of MODIS data
- Rainfall accumulation: aggregate daily CHIRPS rainfall to monthly or seasonal totals
- Land cover change: identify pixels that changed class between years
These operations follow a common pattern: group by time period → apply aggregation → return reduced array.
# Setting Up a Temporal Stack with XArray and Dask
import xarray as xr
import numpy as np
import pandas as pd
# ── Simulate a 3-year daily NDVI stack: (1095, 2000, 2000) ────────────────────
# In practice, load with stackstac or xr.open_mfdataset from GeoTIFF/Zarr
time_index = pd.date_range("2020-01-01", periods=365*3, freq="D")
# Lazy Dask-backed array — chunks of 30 days × 500px × 500px
ndvi = xr.DataArray(
data=np.random.default_rng(0).uniform(-0.1, 0.9, (len(time_index), 2000, 2000)).astype(np.float32),
dims=["time", "y", "x"],
coords={
"time": time_index,
"y": np.linspace(-27.0, -30.0, 2000),
"x": np.linspace(152.5, 155.5, 2000),
},
name="ndvi"
)
# Chunk it for lazy computation (in production, load directly as chunked)
ndvi_dask = ndvi.chunk({"time": 30, "y": 500, "x": 500})
print(ndvi_dask)
# <xarray.DataArray 'ndvi' (time: 1095, y: 2000, x: 2000)>
# dask.array<...> chunks=(30, 500, 500)
# Size: ~8.8 GB on disk, loaded lazily
# GroupBy: Computing Monthly Means
# ── Monthly mean NDVI — entirely lazy until .compute() ────────────────────────
monthly_mean = ndvi_dask.groupby("time.month").mean(dim="time")
# Result shape: (12, 2000, 2000) — one layer per calendar month
# This is still lazy — no computation has run
# Trigger computation
import time
t0 = time.perf_counter()
result = monthly_mean.compute()
elapsed = time.perf_counter() - t0
print(f"Monthly means (3yr × 2000×2000 daily): {elapsed:.1f}s")
# → ~8.2s on 8-core machine with Dask threading scheduler
print(result.shape) # (12, 2000, 2000)
The .groupby("time.month") accessor groups by the month component of the time coordinate (1–12), then .mean(dim="time") reduces across all times within each group. The result is a 12-layer array of climatological monthly means.
# Resample: Calendar-Consistent Aggregation
resample is a time-aware GroupBy that uses Pandas time offsets, producing one output per calendar period:
# ── Monthly mean using resample (more calendar-accurate than groupby.month) ───
monthly_resampled = ndvi_dask.resample(time="1MS").mean() # "1MS" = month start
# Result: one layer per month, labelled with the first day of each month
# e.g. 2020-01-01, 2020-02-01, ..., 2022-12-01 → 36 layers
# ── Quarterly maximum ─────────────────────────────────────────────────────────
quarterly_max = ndvi_dask.resample(time="QS").max()
# 12 layers for 3 years (4 quarters × 3 years)
# ── Annual mean ───────────────────────────────────────────────────────────────
annual_mean = ndvi_dask.resample(time="AS").mean()
# 3 layers
Common time frequency aliases: "D" (day), "W" (week), "MS" (month start), "ME" (month end), "QS" (quarter start), "AS" (year start).
# Loading Real Data: xr.open_mfdataset
For real workflows, data comes from multiple GeoTIFF files — one per scene or per date. open_mfdataset opens them all lazily as a single concatenated array:
import xarray as xr
import glob
# List of GeoTIFFs, one per acquisition date
tif_files = sorted(glob.glob("landsat_ndvi/LC08_*_NDVI.tif"))
# Open all as a single dataset — lazy, no data loaded yet
ds = xr.open_mfdataset(
tif_files,
engine="rasterio", # requires rioxarray
concat_dim="time",
combine="nested",
parallel=True, # open file headers in parallel
preprocess=lambda ds: ds.assign_coords(
time=pd.Timestamp(ds.encoding['source'].split("_")[3])
)
)
ndvi = ds["band_1"].rename({"band_1": "ndvi"})
ndvi_chunked = ndvi.chunk({"time": 30, "y": 512, "x": 512})
With parallel=True, file headers are opened concurrently. Actual data only loads when you call .compute().
# Masking Cloudy Pixels Before Aggregation
Real satellite data requires quality masking before temporal aggregation. Landsat provides a QA_PIXEL band:
import xarray as xr
import numpy as np
# Load QA_PIXEL alongside NDVI
qa = ds["QA_PIXEL"]
# Bit 3: cloud shadow. Bit 4: snow. Bit 5: cloud
cloud_mask = (qa & 0b00111000).astype(bool) # True = cloudy/shadowed/snow
# Mask NDVI: set cloudy pixels to NaN
ndvi_masked = ndvi_chunked.where(~cloud_mask)
# Now monthly mean ignores NaN (masked) values
monthly_mean_masked = ndvi_masked.resample(time="1MS").mean(skipna=True)
skipna=True (the default) means the aggregation computes the mean over only the unmasked observations — equivalent to cloud-free compositing.
# Dask Scheduler Choices
The default Dask scheduler for XArray is the threaded scheduler, which works well for I/O-bound and NumPy operations:
# Default: threaded scheduler (good for most raster work)
result = monthly_mean.compute()
# For CPU-intensive operations on large arrays, try the distributed scheduler:
from dask.distributed import Client
client = Client(n_workers=4, threads_per_worker=2, memory_limit="8GB")
result = monthly_mean.compute()
client.close()
The distributed scheduler adds overhead for small computations but can be significantly faster for large ones by enabling more precise memory management and task graph visualisation.
# Percentile and Custom Aggregations
Not all aggregations are built-in. Custom reductions work with .reduce():
# 90th percentile NDVI per pixel per month (vegetation peak estimate)
def percentile_90(x, axis):
return np.nanpercentile(x, 90, axis=axis)
monthly_p90 = ndvi_masked.resample(time="1MS").reduce(percentile_90)
For complex aggregations, apply_ufunc provides full control while preserving lazy evaluation:
import numpy as np
import xarray as xr
def pixel_trend_slope(ndvi_stack):
"""Compute linear trend slope per pixel using least squares."""
# ndvi_stack: (time,) float array
t = np.arange(len(ndvi_stack), dtype=float)
valid = ~np.isnan(ndvi_stack)
if valid.sum() < 2:
return np.nan
slope, _ = np.polyfit(t[valid], ndvi_stack[valid], 1)
return slope
# Apply pixel-wise, preserving spatial dims
trend_map = xr.apply_ufunc(
pixel_trend_slope,
annual_mean,
input_core_dims=[["time"]],
vectorize=True,
dask="parallelized",
output_dtypes=[float]
)
result_trend = trend_map.compute()
# Writing Results
# Write to Zarr (best for continued XArray work)
monthly_mean_masked.to_zarr("monthly_ndvi.zarr", mode="w")
# Write to multi-band GeoTIFF via rioxarray
import rioxarray
monthly_mean_masked.rio.write_crs("EPSG:32755").to_raster("monthly_ndvi.tif")
# Write individual months as separate TIFFs
for i, month_data in enumerate(monthly_mean_masked):
month_label = f"{month_data.time.dt.strftime('%Y-%m').item()}"
month_data.rio.to_raster(f"monthly_ndvi_{month_label}.tif")
# Memory Management Rule of Thumb
Chunk size should be roughly 100–500 MB in memory after decompression. For float32 data:
- A
(30, 512, 512)chunk = 30 × 512 × 512 × 4 bytes ≈ 31 MB ✓ - A
(1, 2048, 2048)chunk = 1 × 2048 × 2048 × 4 bytes ≈ 16 MB ✓ - A
(1095, 2000, 2000)chunk (unchunked time dim) ≈ 8.8 GB ✗ — OOM
The time dimension should always be chunked when doing temporal aggregations, unless your entire time series fits in memory.
Related reading: XArray and Zarr: Chunked Raster Storage for Cloud Workflows · stackstac: Lazy Satellite Time Series from STAC Catalogues · Rasterio Windowed Reading for Large Rasters