Cloud-Native Raster Workflows for Forestry in Python

The traditional shape of a satellite forestry workflow is: find the scenes, download 40 GB, unzip, reproject everything, then compute over a study area that occupies 2% of what you downloaded. Cloud-native geospatial inverts that: the imagery stays where it is, an index describes what exists, and your code reads only the bytes covering your stands. The concrete problem this page solves is a regional NDVI or burn-severity time series over a forest ownership, computed from public Sentinel-2 or Landsat archives without a bulk download and without a machine large enough to hold a year of imagery. It sits inside Ecological GIS Data Foundations in Python and feeds the analysis in Vegetation Index Calculation in Python.

Three technologies carry the whole pattern, and each solves one problem. The Cloud Optimized GeoTIFF (COG) makes a remote raster randomly readable, so a window costs a few HTTP range requests instead of a file transfer. SpatioTemporal Asset Catalog (STAC) makes the archive searchable by space, time and metadata without a proprietary API. Chunked arrays — xarray over dask — let you express an operation over a hundred scenes as if it were one array and have it execute in bounded memory.

Bulk download versus a cloud-native read path The upper track shows the traditional workflow: a scene search, a full download of every scene, local unzipping and reprojection, then a clip to the study area which discards most of what was transferred. The lower track shows the cloud-native workflow: a STAC search returns asset URLs, HTTP range requests fetch only the internal tiles that overlap the stands, and a lazy chunked array computes the result, so the bytes moved are proportional to the study area rather than to the archive. Bulk download scene search by tile id + date download all 42 GB transferred unzip, warp local disk churn clip to stands 98% discarded result Cloud-native STAC search bbox + datetime + cloud asset URLs COGs, not archives range requests 180 MB transferred lazy chunks xarray + dask result Same answer; the bytes moved scale with the study area instead of with the archive. The saving is not just bandwidth — it is the disk, the warp step, and the version of the data you forgot to delete.

Prerequisites

If the CRS side of that list is not settled, resolve it first with Coordinate Reference Systems for Forestry; a lazy stack built on an ambiguous CRS reprojects wrongly at every chunk and the error is very hard to see.

What makes a GeoTIFF cloud-optimized

A COG is an ordinary GeoTIFF with two disciplines: the pixels are stored in internal tiles rather than strips, and the file carries an overview pyramid, with all of it laid out so the header comes first. A reader can then fetch the header, learn which tiles cover the window it wants, and issue byte-range requests for only those tiles.

The practical consequence for forestry is a change in cost model. Reading a 5 km × 5 km stand from a 110 km Sentinel-2 tile touches a few hundred internal tiles out of tens of thousands, so it costs megabytes rather than the whole scene. Reading a whole scene from a COG is no cheaper than reading a plain GeoTIFF — cloud-native pays when your area of interest is small relative to the archive, which is the normal case in forest management.

Which bytes a windowed read of a COG actually transfers On the left, the file layout of a Cloud Optimized GeoTIFF is drawn as a vertical stack: a small header block, then three overview levels of decreasing size, then the full-resolution tile data. On the right, the full-resolution grid is drawn as a raster of internal tiles with a stand boundary crossing it; only the nine tiles overlapping the stand are shaded, and a caption notes that the read fetches the header plus those nine tiles. File layout header + tile index overview 1:16 overview 1:4 overview 1:2 full-resolution internal tiles 512 × 512 each stands 9 tiles fetched out of 48 — plus the header, and nothing else

Stage 1 — Search the archive with STAC

STAC replaces per-provider APIs with one query grammar: a bounding box, a datetime range, a collection, and arbitrary property filters. Searching does not read pixels, so it is cheap enough to run interactively while you decide what you need. The details of building a robust query, paging results, and filtering on scene-level cloud cover are covered in Searching STAC Catalogs for Sentinel-2 with pystac-client.

What matters at this level is that a search returns items, each holding assets — one per band — whose hrefs point at COGs. Nothing is downloaded. The whole plan of the analysis is decided here: which scenes, which bands, which dates.

Stage 2 — Read only the window you need

With an asset URL in hand, a windowed read costs a few range requests. rasterio handles this transparently through GDAL’s virtual file system; the practical work is converting a study-area geometry into a pixel window in the raster’s own CRS. That conversion — and the surprisingly easy ways to get it wrong — is the subject of Reading Cloud Optimized GeoTIFFs with rasterio Windows.

The environment matters as much as the code. Three GDAL settings change remote read performance by an order of magnitude:

import os

import rasterio

GDAL_ENV = {
    # Without this, every open lists the containing directory — often the dominant cost.
    "GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
    # Only these extensions are considered sidecars, so no speculative .aux.xml requests.
    "CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif,.TIF,.tiff",
    # A block cache large enough to hold the tiles a window touches.
    "GDAL_CACHEMAX": "512",
    "VSI_CACHE": "TRUE",
    "VSI_CACHE_SIZE": "134217728",
}


def open_remote(url: str):
    """Open a remote COG with the settings that make windowed reads fast."""
    with rasterio.Env(**GDAL_ENV):
        return rasterio.open(url)

Stage 3 — Stack scenes lazily

A time series is many items sharing a grid. Building it eagerly means holding every scene in memory; building it lazily means describing the stack and letting dask fetch chunks only as a computation demands them. stackstac and odc-stac both turn a STAC item collection into an xarray.DataArray with dimensions of time, band, y and x — the reprojection to a common grid is declared rather than performed.

The mechanics of chunk sizing, of why an ill-chosen chunk shape can make a lazy stack slower than a download, and of when to persist an intermediate, are in Chunked Raster Processing with xarray and dask.

The pattern for a forest NDVI time series is short enough to state here in full:

import stackstac
import pystac_client


def ndvi_series(bbox, start: str, end: str, epsg: int = 32633, res: int = 20):
    """Lazy NDVI time series over a bounding box; nothing is read until .compute()."""
    client = pystac_client.Client.open("https://earth-search.aws.element84.com/v1")
    search = client.search(
        collections=["sentinel-2-l2a"],
        bbox=bbox,
        datetime=f"{start}/{end}",
        query={"eo:cloud_cover": {"lt": 40}},
    )
    items = list(search.items())

    stack = stackstac.stack(
        items,
        assets=["red", "nir", "scl"],
        epsg=epsg,
        resolution=res,
        bounds_latlon=bbox,
        chunksize=1024,
    )
    red = stack.sel(band="red").astype("float32")
    nir = stack.sel(band="nir").astype("float32")
    scl = stack.sel(band="scl")

    # Keep only vegetation, bare soil, water and unclassified; drop cloud and shadow.
    clear = scl.isin([4, 5, 6, 7])
    ndvi = ((nir - red) / (nir + red)).where(clear)
    return ndvi.rename("ndvi")

Note what has not happened: no scene was read, no reprojection ran, and the result is a description of an array. A subsequent .median(dim="time").compute() fetches only the chunks that median needs.

What a reduction actually fetches from a lazy stack Four dated slices of a stacked time series are drawn side by side, each divided into a three by three grid of spatial chunks. A median taken over the time dimension needs only the chunk column covering the stands, so in every slice the same single chunk is shaded as fetched and the remaining eight stay unfetched. A caption records that the transfer is four chunks rather than thirty-six. 2026-04-11 2026-05-06 2026-06-20 2026-07-15 chunk fetched chunk fetched chunk fetched chunk fetched median over time 4 chunks transferred, not 36 — the reduction decides what the network does.

Stage 4 — Keep provenance with the result

A cloud-native workflow removes the one artefact that used to prove what you analysed: the downloaded file. Reproducibility now depends on recording the query and the item identifiers, because the archive can and does change — scenes are reprocessed, baselines shift, and a collection’s definition of a band can be revised.

import json
import hashlib
from datetime import datetime, timezone


def write_provenance(items, params: dict, out_path: str) -> str:
    """Record exactly which STAC items produced a result, and how."""
    ids = sorted(item.id for item in items)
    record = {
        "generated_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "n_items": len(ids),
        "item_ids": ids,
        "item_id_digest": hashlib.sha256("\n".join(ids).encode()).hexdigest(),
        "parameters": params,
    }
    with open(out_path, "w", encoding="utf-8") as fh:
        json.dump(record, fh, indent=2)
    return record["item_id_digest"]

Store that JSON beside every derived raster. When a result cannot be reproduced a year later, the digest tells you immediately whether the input set changed or your code did — a distinction that is otherwise nearly impossible to recover.

The Python library ecosystem

Package Version Role
rasterio ≥ 1.3 Windowed reads over /vsicurl/, COG writing, warped VRTs
pystac-client ≥ 0.7 STAC search, paging, item and asset access
stackstac ≥ 0.5 STAC items to a lazy xarray stack on a declared grid
odc-stac ≥ 0.3 The same job with more control over resampling and grouping
xarray ≥ 2023.1 Labelled dimensions so a time series is indexed by date, not position
dask ≥ 2023.1 Chunked, out-of-core execution with a bounded memory footprint
rioxarray ≥ 0.15 CRS-aware clipping, reprojection and writing for xarray objects
planetary-computer ≥ 1.0 Asset signing for the Microsoft Planetary Computer catalogue
python -m pip install "rasterio>=1.3" "pystac-client>=0.7" "stackstac>=0.5" \
    "xarray>=2023.1" "dask[array]>=2023.1" "rioxarray>=0.15" geopandas

Production principles

Declare the grid once. Every scene should be resampled to one CRS, resolution and origin at stack construction, not per operation. A stack whose grid is decided implicitly by the first item will silently shift when that item changes.

Pin the collection, not just the dates. sentinel-2-l2a from two providers can differ in processing baseline and scaling. Record the catalogue URL and collection id in the provenance file.

Cache deliberately. Repeated interactive work over the same stands should persist an intermediate — a clipped, gridded, cloud-masked stack written once as a local COG or Zarr store — rather than re-fetching. The cloud-native argument is against bulk downloads, not against caching what you have already decided you need.

Fail on the CRS, never coerce it. If an item’s CRS is not what the stack declared, that is a data problem worth an exception. Silently reprojecting a stray scene produces a time series with one misaligned date, which looks exactly like real change.

Budget requests, not just bytes. A stack of 200 dates × 3 bands is 600 remote datasets. Opening them serially can take longer than the reads. Use a thread pool for opens and keep GDAL_DISABLE_READDIR_ON_OPEN set.

When Cloud-Native Is the Wrong Choice

The pattern has a cost, and it is worth naming the cases where paying it is a mistake.

The first is a study area that covers most of a scene. Windowed reads win because they transfer a fraction of the archive; when the fraction approaches one, they transfer the same bytes with more requests and more latency. A national analysis at 20 m over a country that fits inside two Sentinel-2 tiles is faster to download once.

The second is repeated interactive work over the same small area. The first read of a remote scene pays for the header and the tile index, and that cost recurs in every new process. Persisting a clipped, gridded, cloud-masked stack locally after the first pass turns an afternoon of exploration from a sequence of network round trips into local array work. Cloud-native is an argument against bulk downloads, not against caching what you have already decided you need.

The third is anything that must be defensible over years. Archives are not immutable: items are reprocessed, occasionally withdrawn, and a collection’s band definitions can be revised. A published result that reruns against a live catalogue may not reproduce, and without recorded item identifiers there is no way to tell whether the code or the data moved. For work of that kind, persist the exact stack used and treat the cloud-native path as how you found the data rather than how you store it.

The fourth is a compute-bound analysis. The lazy-stack pattern is optimised for I/O-bound work, where latency dominates and a threaded scheduler is the right tool. A per-pixel model fit over a large region is bound by CPU instead, and the same code will be slower than a straightforwardly parallel loop over materialised chunks, because the scheduler is solving the wrong problem.

Provenance in Practice

Because the downloaded file no longer exists as evidence, a cloud-native workflow has to manufacture its own. The minimum record is small: the catalogue URL, the collection identifier, the full query, the sorted list of item identifiers with a digest over them, the declared CRS, resolution and grid origin, and the code version that produced the result. Written as a JSON sidecar next to every derived raster, that record answers the question that otherwise cannot be answered a year later — whether a result that will not reproduce changed because the analysis changed or because the archive did.

It is also worth recording what was excluded. A cloud mask drops observations, a cloud-cover filter drops scenes, and a date window drops seasons; a composite built from three clear observations and one built from thirty are rendered identically and mean very different things. Shipping the per-pixel clear-observation count alongside the composite is the cheapest uncertainty layer available, and it is the one most often left out.

Frequently Asked Questions

Is a cloud-native workflow always faster?

No, and the exception matters. When the study area covers most of a scene, or the analysis touches every pixel of a small archive, downloading is competitive and simpler. Cloud-native wins when the area of interest is small relative to the archive and when the time dimension is long — which describes almost all forest monitoring, and very little field-campaign work.

What happens when the archive changes underneath me?

Items get reprocessed and occasionally withdrawn. That is exactly why the item identifiers belong in a provenance record: rerunning the same query later can return a different set, and without the record the difference is invisible. For work that must be defensible over years, persist the clipped stack you actually used.

Do I need a dask cluster?

Rarely. The local threaded scheduler handles a single ownership over a decade of Sentinel-2 comfortably, because the work is dominated by network latency rather than by computation. Reach for a distributed cluster when the analysis is genuinely compute-bound — a per-pixel model fit over a large region, for instance — not merely because the data is large.

Can I use these patterns with my own imagery?

Yes, and it is usually worth it. Converting an internal archive to COGs and publishing a static STAC catalogue — a set of JSON files, no server — gives the same windowed reads and the same lazy stacking over your own LiDAR-derived rasters and drone mosaics.

Up: Ecological GIS Data Foundations in Python