Loading STAC Items into an xarray Cube with stackstac
Declare the output grid explicitly and the rest is lazy:
import stackstac
cube = stackstac.stack(
items,
assets=["red", "nir", "scl"],
epsg=32636, resolution=10,
bounds=(760_000, 9_940_000, 800_000, 9_980_000), # in the target CRS
chunksize=2048,
)
# (time, band, y, x) — nothing has been read yet
Letting stackstac infer the grid from the items is the most common way to build a cube far larger than intended. This page belongs to querying STAC catalogs programmatically in Core Raster Fundamentals & STAC Mapping.
From Search Results to a Cube
The declared grid is what makes the cube usable. Every date shares one transform, so a pixel at (y, x) is the same ground location at every time step, and arithmetic across time or bands needs no further alignment. The trade-offs between this library and its main alternative are set out in stackstac vs odc-stac for STAC-to-array.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
stackstac |
>=0.5 |
Building the lazy cube from items |
pystac-client |
>=0.7 |
Searching the catalog |
dask[array] |
>=2024.1 |
Lazy chunked execution |
rioxarray |
>=0.15 |
Writing results back out with CRS |
pip install "stackstac>=0.5" "pystac-client>=0.7" "dask[array]>=2024.1" "rioxarray>=0.15"
Complete Working Example
import numpy as np
import pystac_client
import stackstac
import xarray as xr
API = "https://earth-search.aws.element84.com/v1"
SCL_KEEP = [4, 5, 6, 11] # vegetation, not-vegetated, water, snow
def search(bbox_wgs84, period: str, max_cloud: float = 40):
client = pystac_client.Client.open(API)
return client.search(collections=["sentinel-2-l2a"], bbox=bbox_wgs84,
datetime=period,
query={"eo:cloud_cover": {"lt": max_cloud}}).item_collection()
def ndvi_cube(items, *, epsg: int, bounds: tuple[float, float, float, float],
resolution: int = 10, chunksize: int = 2048) -> xr.DataArray:
cube = stackstac.stack(
items,
assets=["red", "nir", "scl"],
epsg=epsg, resolution=resolution, bounds=bounds,
chunksize=chunksize,
rescale=False, # apply scale/offset explicitly, per scene
fill_value=np.nan, dtype="float32",
)
scl = cube.sel(band="scl")
clear = scl.isin(SCL_KEEP)
# Per-scene radiometry: newer baselines carry an offset of -0.1
offset = xr.where(cube["s2:processing_baseline"].astype(str) >= "04.00", -0.1, 0.0)
red = cube.sel(band="red") * 1e-4 + offset
nir = cube.sel(band="nir") * 1e-4 + offset
ndvi = ((nir - red) / (nir + red)).where(clear)
ndvi.name = "ndvi"
return ndvi
if __name__ == "__main__":
items = search([34.6, 0.25, 34.9, 0.55], "2026-04-01/2026-09-30")
ndvi = ndvi_cube(items, epsg=32636,
bounds=(760_000, 9_940_000, 800_000, 9_980_000))
print(ndvi) # lazy
seasonal = ndvi.median("time", skipna=True).compute() # now reads
Turning off rescale and applying the radiometry explicitly is a deliberate choice for Sentinel-2. The processing-baseline offset differs between scenes in the same search, and a single global rescale cannot represent that; the per-scene expression above can, because stackstac exposes item properties as coordinates on the time dimension. The reasoning is set out in converting int16 reflectance to float safely.
How Big Is the Cube, Really?
cube.nbytes and cube.shape are computed from metadata alone, so they are free to inspect and they answer the question that matters before anything is read. Make a habit of printing both after every stack call; an unexpectedly large number almost always means inferred bounds or a resolution that was not set.
Reducing before computing matters just as much. ndvi.median("time").compute() reads every pixel of every date once and returns one layer; ndvi.compute().median("time") materialises the whole cube in memory first. The lazy graph makes both look identical until one of them runs out of memory.
Grouping Split Acquisitions by Solar Day
A single satellite pass over an area of interest often produces several items — one per MGRS tile it intersects — all with nearly identical timestamps. Stacking them yields separate time steps that each cover part of the area and are NaN elsewhere, which is correct but awkward: a “date” in the cube is then only partly observed, and counting valid observations per pixel double-counts nothing but looks as if it does.
Grouping those items by solar day merges them into one complete observation per pass:
import numpy as np
# One observation per pass: first valid value across items from the same day
daily = ndvi.groupby(ndvi.time.dt.floor("D")).first(skipna=True)
daily = daily.rename({"floor": "time"})
first(skipna=True) takes, at each pixel, the first non-NaN value among that day’s items — which, since the tiles do not overlap much, is simply the value from whichever tile covered that pixel. Do this before any temporal statistics, so that seasonal percentiles and observation counts refer to passes rather than to catalog records. The downstream reductions are covered in temporal aggregation and time series analysis.
Verification
cube = stackstac.stack(items, assets=["red", "nir", "scl"], epsg=32636,
resolution=10, bounds=(760_000, 9_940_000, 800_000, 9_980_000))
assert cube.rio.crs is None or cube.rio.crs.to_epsg() == 32636 or cube.attrs["crs"].endswith("32636")
print(cube.shape, f"{cube.nbytes / 1e9:.1f} GB if fully read")
assert cube.sizes["time"] == len(items)
assert set(cube.band.values) == {"red", "nir", "scl"}
The time-step count catches one quiet failure: items with identical timestamps are kept as separate steps, so a scene split across two MGRS tiles appears twice. That is usually wanted, and grouping by solar day afterwards merges them — but it should be a deliberate step rather than a surprise in a later median.
Common Errors
The cube is hundreds of gigabytes
Bounds were inferred from items spanning several tiles. Pass bounds for the area of interest.
ValueError: Cannot pick a common CRS
Items span several UTM zones and no epsg was given. Choose one explicitly.
Every value is NaN after masking
The SCL band was resampled bilinearly, so class codes became fractions. Stackstac uses nearest by default; check that nothing forced another method.
Indices jump at a date in the middle of the series
The processing-baseline offset was not applied per scene. Apply it from each item’s properties.
Frequently Asked Questions
Q: Does stackstac download the imagery? Not when you call stack — it returns a lazy Dask-backed DataArray built from the item metadata. Pixels are read only when a computation needs them, and then only the windows that computation touches.
Q: Why pass bounds and epsg explicitly? Because the default grid is derived from the items’ footprints, which for a search spanning several tiles or UTM zones can be very large and in an unexpected projection. Declaring the grid makes the cube’s size and alignment a choice rather than a consequence.
Q: How do I avoid reading more than I need? Select assets, clip bounds to the area of interest, filter by cloud cover in the search, and reduce over time before computing. Each of those cuts the bytes read, and together they are usually the difference between minutes and hours.
Q: Can I write the cube straight to Zarr? Yes, after choosing a chunking that suits how it will be read. Writing a stackstac cube to Zarr materialises it once, which turns repeated catalog reads into fast local-region reads, as described in writing an xarray datacube to Zarr.
Related
- Querying STAC Catalogs Programmatically — the parent topic.
- Stackstac vs odc-stac for STAC-to-Array — choosing between the two loaders.
- Masking Clouds with the Sentinel-2 SCL Band — the mask applied above.
- Creating Monthly NDVI Composites with xarray resample — reducing the cube over time.