Skip to content

python-remote-sensing/stac-fetch

Repository files navigation

stac-fetch

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.

The problem

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.

Install

Python 3.12 or newer.

git clone https://github.com/python-remote-sensing/stac-fetch.git
cd stac-fetch
pip install -r requirements.txt

That 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, zarr

There is nothing to install into site-packages — run it out of the clone with python -m stac_fetch.

Usage

What is out there? (reconnaissance)

$ 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 more

The table goes to stdout, the summary and any truncation warning go to stderr, so ... | head stays clean.

JSON for pipelines

$ 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.

Download assets

$ 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 -> ./scenes

Run 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 -> ./scenes

Kill it half way through and start it again, and each partial file resumes from where it stopped rather than restarting.

Planetary Computer

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 ./scenes

Signing 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.

Build a datacube

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.zarr

As a library

from 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.

How it works

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.

Options

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.

Library API

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

Limitations

  • --aoi uses the geometry's bounding box, not the polygon itself. For true polygon intersection pass a GeoJSON geometry to search(intersects=...) from Python.
  • Bounding boxes that cross the antimeridian are rejected rather than silently interpreted. Split them into two searches.
  • --sortby is 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.
  • --query covers the six comparison operators (=, !=, <, <=, >, >=). There is no IN, LIKE or spatial predicate; for those, build a CQL2 document and use pystac-client directly.
  • Checksums are only verified for sha2-256 file:checksum multihashes. 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.

Further reading

Background guides that go deeper on the mechanics this tool automates:

Contributing

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 pytest

License

MIT — see LICENSE.

Built and maintained alongside Python Remote Sensing & Raster Processing Pipelines.

About

Query STAC catalogs from the CLI and turn results into a listing, downloaded COGs, or a lazy xarray datacube

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages