Query any STAC catalog from the command line and turn the result into a listing, a set of downloaded COGs, or a lazy xarray datacube.
Every satellite-imagery project starts with the same twenty minutes of friction. You want to know
what Sentinel-2 scenes cover a basin in July under 20% cloud, so you open a notebook, look up the
Earth Search endpoint again, remember that datetime wants an RFC 3339 interval and not
2024-07, remember that eo:cloud_cover goes in query and not in the top level, then discover
that search.items() quietly stopped at the first page and you have been reasoning about 100
scenes when there were 1,400.
Then you want the pixels. So you write the download loop again: threads, a .part file so an
interrupted run does not start from zero, a size check so a truncated GeoTIFF does not poison the
pipeline three steps later, a skip-if-present check so re-running is cheap. And if the catalog is
Planetary Computer, everything 403s until you remember the assets need signing.
stac-fetch is that twenty minutes, packaged. It is a reconnaissance tool first — --print tells
you what exists before you commit to moving bytes — and a transfer tool second.
Python 3.12 or newer.
git clone https://github.com/python-remote-sensing/stac-fetch.git
cd stac-fetch
pip install -r requirements.txtThat is enough for searching, listing and downloading. The datacube mode (--to-zarr,
to_datacube()) needs a heavier optional stack:
pip install -r requirements-datacube.txt # stackstac, xarray, dask, rioxarray, zarrThere is nothing to install into site-packages — run it out of the clone with
python -m stac_fetch.
$ python -m stac_fetch -u earth-search -c sentinel-2-l2a \
--bbox 5.9,45.8,6.5,46.4 -d 2024-07 --cloud-cover 20 --sortby eo:cloud_cover --limit 5
warning: Result set truncated: returned 5 of 14 matching items (--limit 5). Raise or drop --limit to fetch the rest.
5 item(s) of 14 matching from https://earth-search.aws.element84.com/v1
ID DATETIME CLOUD% ASSETS
------------------------ -------------------- ------ --------------------------------------
S2A_31TGM_20240725_0_L2A 2024-07-25T10:48:05Z 4.4 aot, aot-jp2, blue, blue-jp2, +31 more
S2B_31TGL_20240717_0_L2A 2024-07-17T10:38:25Z 5.0 aot, aot-jp2, blue, blue-jp2, +31 more
S2B_32TLS_20240720_0_L2A 2024-07-20T10:48:01Z 5.8 aot, aot-jp2, blue, blue-jp2, +31 more
S2B_31TGL_20240727_0_L2A 2024-07-27T10:38:24Z 6.3 aot, aot-jp2, blue, blue-jp2, +31 more
S2A_32TLS_20240725_0_L2A 2024-07-25T10:48:00Z 6.6 aot, aot-jp2, blue, blue-jp2, +31 moreThe table goes to stdout, the summary and any truncation warning go to stderr, so
... | head stays clean.
$ python -m stac_fetch -u earth-search -c sentinel-2-l2a --aoi basin.geojson \
-d last-30-days --cloud-cover 20 --json
{
"search": {
"catalog": "https://earth-search.aws.element84.com/v1",
"returned": 2,
"matched": 14,
"limit": 2,
"truncated": true
},
"items": [
{
"id": "S2A_31TGM_20240725_0_L2A",
"collection": "sentinel-2-l2a",
"datetime": "2024-07-25T10:48:05Z",
"cloud_cover": 4.447231,
"bbox": [5.5795144, 45.9187689, 6.7387394, 46.9235637],
"assets": ["aot", "blue", "green", "nir", "red", "scl", "..."],
"asset_details": {
"red": {
"href": "https://.../B04.tif",
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
"title": "Red (band 4) - 10m"
}
}
}
]
}truncated is the field to assert on in a pipeline: it is true whenever --limit stopped the
walk before the result set was exhausted.
$ python -m stac_fetch -u earth-search -c sentinel-2-l2a --bbox 5.9,45.8,6.5,46.4 \
-d 2024-07 --cloud-cover 20 --limit 3 -a red -a nir --download ./scenes --workers 8
[1/6] downloaded S2B_31TGL_20240730_0_L2A/red 112.4 MiB
[2/6] downloaded S2B_31TGL_20240730_0_L2A/nir 118.9 MiB
[3/6] downloaded S2B_31TGL_20240727_0_L2A/red 111.7 MiB
...
6 asset(s): 6 downloaded -> ./scenesRun it again and nothing moves:
$ python -m stac_fetch -u earth-search -c sentinel-2-l2a --bbox 5.9,45.8,6.5,46.4 \
-d 2024-07 --cloud-cover 20 --limit 3 -a red -a nir --download ./scenes
[1/6] skipped S2B_31TGL_20240730_0_L2A/red -
[2/6] skipped S2B_31TGL_20240730_0_L2A/nir -
...
6 asset(s): 6 skipped -> ./scenesKill it half way through and start it again, and each partial file resumes from where it stopped rather than restarting.
pip install planetary-computer
python -m stac_fetch -u pc -c sentinel-2-l2a --bbox 5.9,45.8,6.5,46.4 \
-d last-30-days -a B04 -a B08 --download ./scenesSigning is automatic for that host. Without the optional package you get
error: Signing Microsoft Planetary Computer assets requires the 'planetary-computer' package, which is not installed. Install it with: pip install planetary-computer — not a 403 twenty
minutes into a transfer.
python -m stac_fetch -u earth-search -c sentinel-2-l2a --bbox 5.9,45.8,6.5,46.4 \
-d 2024-07 --cloud-cover 20 -a red -a nir \
--epsg 32632 --resolution 20 --chunksize 2048 --to-zarr cube.zarrfrom stac_fetch import search, download, to_datacube
result = search(
"earth-search",
collections="sentinel-2-l2a",
bbox="5.9,45.8,6.5,46.4",
datetime="last-30-days",
cloud_cover=20,
sortby="eo:cloud_cover",
limit=20,
)
print(len(result), "of", result.matched, "truncated:", result.truncated)
download(result, ["red", "nir"], "./scenes", workers=8)
cube = to_datacube(result, assets=["red", "nir"], resolution=20, epsg=32632)
ndvi = (cube.sel(band="nir") - cube.sel(band="red")) / (cube.sel(band="nir") + cube.sel(band="red"))search() returns a SearchResult, which is a pystac.ItemCollection subclass — it hands
straight to stackstac.stack(), odc.stac.load() or anything else that eats items — with
matched, truncated and limit attached.
Query construction. The friendly forms are translated to exactly what the STAC API spec wants
before a request is made. 2024-07 becomes 2024-07-01T00:00:00Z/2024-07-31T23:59:59Z — note
that the end snaps to the last instant of the period, which is the difference between catching
and missing the last scene of the month. last-30-days is resolved against the current UTC time.
--cloud-cover 20 becomes {"eo:cloud_cover": {"lte": 20}} under the Query extension, or
{"op": "<=", "args": [{"property": "eo:cloud_cover"}, 20]} under --filter-lang cql2-json for
catalogs that implement the Filter extension instead. Bounding boxes are validated (range, and
minx <= maxx) locally, because a transposed bbox otherwise comes back as a confusing empty
result rather than an error.
Pagination. A STAC search response is one page with a next link; the item count you get from
a naive search() is the page size, not the match count. stac-fetch walks every next link to
exhaustion. When you deliberately cap the walk with --limit, the cap is reported — on stderr as
a warning and in --json as search.truncated — so a truncated result can never be mistaken for
a complete one. The numberMatched the server reports is carried through as matched for the
same reason.
Signing. Planetary Computer asset hrefs are blob-store URLs that need a short-lived SAS token.
Signing is applied after the search and only in the modes that actually read bytes, so listing
stays cheap and no token is minted for a query you were only eyeballing. --sign auto (the
default) keys off the catalog host; --sign none and --sign planetary-computer override it.
Downloads. Each asset goes to <name>.part first and is os.replaced into place only after
it verifies, so a destination file is either absent or complete — never a truncated GeoTIFF that
fails three pipeline stages later. If a .part file survives an interrupted run, the next run
sends Range: bytes=N-; a server that ignores the range and answers 200 triggers a clean
restart rather than a corrupt append, and a 416 discards the partial and starts over.
Verification uses file:size from the File extension when the catalog publishes it, otherwise the
Content-Length of the response; a file:checksum multihash is verified when it is a sha2-256
one. Skip-if-present compares the existing file's size to file:size where known, so a file
truncated by an earlier crash is refetched instead of trusted. Transfers run on a thread pool,
which is the right shape for this: the work is entirely I/O-bound on remote object storage.
Datacube. stackstac.stack() (or odc.stac.load()) reads only STAC metadata to lay out the
grid, so building the cube costs no pixel reads — the Dask graph is materialised lazily when you
compute or write. --chunksize sets the Dask chunk in pixels along x and y, which is the knob
that decides whether the eventual compute fits in memory.
| Flag | Meaning |
|---|---|
-u, --url URL|PRESET |
STAC API root, or a preset: planetary-computer (pc), earth-search (aws), landsatlook (usgs). Default earth-search |
-c, --collection ID |
Collection to search; repeatable |
--header K=V |
Extra HTTP header, e.g. --header 'Authorization=Bearer ...'; repeatable |
--sign {auto,planetary-computer,none} |
Asset signing mode. Default auto |
-d, --datetime SPEC |
2024-06-01, 2024-06, 2024, 2024-06-01/2024-08-31, 2024-06-01/.., last-30-days, last-6-months, today, yesterday |
--bbox MINX,MINY,MAXX,MAXY |
Bounding box in EPSG:4326 |
--aoi PATH|WKT |
GeoJSON file, inline GeoJSON, or WKT geometry; its bounding box is used |
-q, --query EXPR |
Property filter, e.g. platform=sentinel-2a, view:off_nadir<=10; repeatable |
--cloud-cover PCT |
Shortcut for eo:cloud_cover<=PCT |
--filter-lang {query,cql2-json} |
Filter encoding. Default query |
--limit N |
Stop after N items in total (reported as truncation) |
--page-size N |
Items per HTTP request. Default 100 |
--sortby KEYS |
Comma-separated sort keys, - prefix for descending |
--print / --json |
Table (default) or machine-readable JSON |
--download DIR |
Download --asset keys into DIR |
--to-zarr PATH |
Build a datacube and write it to a Zarr store |
-a, --asset KEY |
Asset key to download or stack; repeatable |
--workers N |
Concurrent downloads. Default 4 |
--overwrite |
Re-download files that already exist |
--per-item-dirs |
One subdirectory per item instead of a flat directory |
--no-progress |
Suppress per-file progress lines |
--engine {auto,stackstac,odc-stac} |
Datacube backend. Default auto |
--resolution M, --epsg CODE, --bounds, --chunksize N |
Datacube grid controls |
--list-catalogs |
Print the presets and exit |
--quiet |
Suppress informational stderr messages |
Exit codes: 0 success, 1 the operation failed (no matches, unreachable catalog, missing asset
key, failed transfer), 2 the invocation was invalid (bad bbox, unparseable datetime, conflicting
flags). Errors are always a single error: ... line, never a traceback.
search(url, *, collections=None, datetime=None, bbox=None, aoi=None, intersects=None,
query=None, cloud_cover=None, limit=None, page_size=None, sortby=None,
filter_lang=None, headers=None, client=None) -> SearchResult
download(items, assets, dest, *, workers=4, overwrite=False, flat=True,
progress=True, session=None, timeout=(10, 120)) -> list[DownloadResult]
to_datacube(items, *, assets=None, resolution=None, epsg=None, bounds=None,
chunksize=1024, engine="auto", fill_value=None)
write_zarr(cube, path, *, mode="w") -> str--aoiuses the geometry's bounding box, not the polygon itself. For true polygon intersection pass a GeoJSON geometry tosearch(intersects=...)from Python.- Bounding boxes that cross the antimeridian are rejected rather than silently interpreted. Split them into two searches.
--sortbyis forwarded to the catalog. Not every STAC API implements the Sort extension, and several ignore it silently — check the returned order before relying on it.--querycovers the six comparison operators (=,!=,<,<=,>,>=). There is noIN,LIKEor spatial predicate; for those, build a CQL2 document and usepystac-clientdirectly.- Checksums are only verified for sha2-256
file:checksummultihashes. Other algorithms are ignored rather than treated as failures. - Resume relies on the server honouring HTTP
Range. Most object stores do; one that does not causes a full re-download, not a corrupt file. - The datacube mode is a thin, well-typed wrapper over
stackstac/odc-stac. Anything beyond resolution, CRS, bounds and chunking is better done on the returned xarray object.
Background guides that go deeper on the mechanics this tool automates:
- Paginating large STAC searches with pystac-client — why a naive
items()call under-reports, and how thenext-link walk works. - Using pystac-client to filter Sentinel-2 imagery by date — the datetime interval semantics behind
--datetime. - stackstac vs odc-stac for STAC-to-array — which backend to pick for
--engine, and how they differ. - Reading a COG over S3 without downloading — when you should skip
--downloadentirely. - Reducing S3 egress costs in raster pipelines — why skip-if-present and resume matter once transfers are billed.
Issues and pull requests are welcome. Please keep the test suite hermetic — no test may touch the
network; add STAC JSON to tests/fixtures/ and mock HTTP with responses. Before opening a PR:
pip install -r requirements-dev.txt
ruff check . && ruff format --check . && python -m pytestMIT — see LICENSE.
Built and maintained alongside Python Remote Sensing & Raster Processing Pipelines.