STAC Explained: How Satellite Imagery Catalogues Work
Problem statement
You need a cloud-free Sentinel-2 scene over one city, from last July. Traditionally that meant creating an account on a portal, learning its search form, working out which of six product levels you wanted, queuing an order, waiting, and downloading a 1.2 GB ZIP β to use one 10 m band over an area 1/200th the size of the scene.
STAC β the SpatioTemporal Asset Catalog β replaces all of that with a JSON API and a URL you can read directly:
from pystac_client import Client
catalog = Client.open("https://earth-search.aws.element84.com/v1")
search = catalog.search(
collections=["sentinel-2-l2a"],
bbox=[-2.30, 53.44, -2.18, 53.52],
datetime="2026-06-01/2026-08-01",
query={"eo:cloud_cover": {"lt": 20}},
)
print(search.matched(), "scenes")
5 scenes
No account, no order queue, no download. The problem STAC solves is not storage β it is search. Imagery archives are petabytes; the hard part was always finding the twelve scenes that matter.
Quick answer
STAC is four nested things, and knowing which is which explains every API call:
| Concept | What it is | Analogy |
|---|---|---|
| Catalog | the entry point, listing collections | the library |
| Collection | one dataset, with shared metadata | one periodical |
| Item | one scene at one time and place | one issue |
| Asset | one downloadable file on an item | one page β a band, a thumbnail, metadata |
An Item is a GeoJSON Feature with extra required fields. That is the whole trick: the geometry is the scene footprint, the properties are the metadata, and the assets dict points at the actual pixels.
item = next(search.items())
print(item.id, "|", item.datetime.date(), "| cloud", round(item.properties["eo:cloud_cover"], 2))
print("assets:", list(item.assets)[:8])
S2B_30UWE_20260712_0_L2A | 2026-07-12 | cloud 0.06
assets: ['aot', 'blue', 'cloud', 'coastal', 'granule_metadata', 'green', 'nir', 'nir08']
Step-by-step solution
1. Find the catalogue and list its collections
catalog = Client.open("https://earth-search.aws.element84.com/v1")
print(catalog.title)
print([c.id for c in catalog.get_collections()])
Earth Search by Element 84
['sentinel-2-pre-c1-l2a', 'cop-dem-glo-30', 'naip', 'cop-dem-glo-90',
'landsat-c2-l2', 'sentinel-2-l2a', 'sentinel-2-l1c', 'sentinel-2-c1-l2a',
'sentinel-1-grd']
Nine collections, and the names encode the product level. sentinel-2-l1c is top-of-atmosphere reflectance; sentinel-2-l2a is surface reflectance, atmospherically corrected. Unless you have a reason to do your own correction, you want L2A.
Note cop-dem-glo-30 in the same catalogue β a global 30 m elevation model, searchable by exactly the same API. STAC is not only for optical imagery.
2. Search on the three things every collection supports
search = catalog.search(
collections=["sentinel-2-l2a"], # what
bbox=[-2.30, 53.44, -2.18, 53.52], # where β always WGS84 degrees
datetime="2026-06-01/2026-08-01", # when β RFC 3339, `..` for open-ended
)
print(search.matched())
23
bbox is always in WGS84 degrees, regardless of what CRS the imagery is stored in. This is fixed by the specification, and it is one of the few things in geospatial data access that is never ambiguous.
datetime accepts a single instant, a closed range (start/end), or a half-open one (2026-06-01/..).
3. Filter on collection-specific properties
Beyond the big three, every collection publishes its own properties, and you can query them:
search = catalog.search(
collections=["sentinel-2-l2a"],
bbox=[-2.30, 53.44, -2.18, 53.52],
datetime="2026-06-01/2026-08-01",
query={"eo:cloud_cover": {"lt": 20}},
)
print(search.matched(), "of 23 scenes under 20% cloud")
5 of 23 scenes under 20% cloud
Eighteen scenes eliminated without downloading a byte. eo:cloud_cover comes from the EO extension β STAC's core is deliberately small, and domain fields arrive through named extensions (eo: for optical, sar: for radar, proj: for projection, view: for geometry).
4. Read the assets, not the item
An Item is metadata. The pixels are in item.assets, one entry per band or file:
red = item.assets["red"]
print(red.media_type)
print(red.href[:80])
image/tiff; application=geotiff; profile=cloud-optimized
https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/30/U/WE/β¦
profile=cloud-optimized is the part that changes how you work. A Cloud-Optimized GeoTIFF is internally tiled with overviews, and HTTP range requests let a client read one tile without fetching the file. That is why you never need to download the scene.
5. Check the footprint before trusting the search
import geopandas as gpd
from shapely.geometry import shape, box
footprint = shape(item.geometry)
study = box(-2.30, 53.44, -2.18, 53.52)
print(f"study area covered: {study.intersection(footprint).area / study.area:.1%}")
study area covered: 100.0%
bbox search returns scenes whose footprint intersects your box β a scene clipping one corner matches as readily as one covering everything. For anything mosaicked, check coverage per scene and combine, as in merging and mosaicking rasters.
Code examples
Example 1 β choosing the best scene by an explicit rule
from pystac_client import Client
from shapely.geometry import shape, box
STUDY = (-2.30, 53.44, -2.18, 53.52)
def best_scene(collection, bbox, datetime, *, max_cloud=20, min_coverage=0.95):
catalog = Client.open("https://earth-search.aws.element84.com/v1")
search = catalog.search(
collections=[collection], bbox=list(bbox), datetime=datetime,
query={"eo:cloud_cover": {"lt": max_cloud}},
)
area = box(*bbox)
scored = []
for item in search.items():
coverage = area.intersection(shape(item.geometry)).area / area.area
if coverage < min_coverage:
continue
scored.append((item.properties["eo:cloud_cover"], coverage, item))
if not scored:
raise LookupError(f"no scene under {max_cloud}% cloud covering {min_coverage:.0%}")
scored.sort(key=lambda t: t[0])
for cloud, coverage, item in scored:
print(f" {item.id:28} cloud {cloud:5.2f}% coverage {coverage:5.1%}")
return scored[0][2]
chosen = best_scene("sentinel-2-l2a", STUDY, "2026-06-01/2026-08-01")
print("chosen:", chosen.id, chosen.datetime.date())
S2B_30UWE_20260712_0_L2A cloud 0.06% coverage 100.0%
S2A_30UWE_20260627_0_L2A cloud 4.31% coverage 100.0%
S2B_30UWE_20260622_0_L2A cloud 11.87% coverage 100.0%
chosen: S2B_30UWE_20260712_0_L2A 2026-07-12
The selection rule is code, not a click in a portal β so it is reproducible, reviewable and rerunnable next season.
A caveat on eo:cloud_cover: it is a scene-wide percentage. A scene at 0.06% cloud can still have its only cloud sitting on your study area. For serious work, read the scene's cloud mask over your extent instead of trusting the summary number.
Example 2 β reading only your study area from a 10,980-pixel scene
import os
import time
os.environ["GDAL_DISABLE_READDIR_ON_OPEN"] = "EMPTY_DIR" # don't list the bucket
os.environ["AWS_NO_SIGN_REQUEST"] = "YES" # public data, no credentials
import pyproj
import rasterio
from rasterio.windows import from_bounds
start = time.perf_counter()
with rasterio.open(chosen.assets["red"].href) as src:
print(f"scene: {src.width} x {src.height} px, {src.crs}, {src.dtypes[0]}")
print(f"internal tiles: {src.block_shapes[0]}, overviews: {src.overviews(1)}")
to_scene = pyproj.Transformer.from_crs("EPSG:4326", src.crs, always_xy=True)
left, bottom = to_scene.transform(STUDY[0], STUDY[1])
right, top = to_scene.transform(STUDY[2], STUDY[3])
window = from_bounds(left, bottom, right, top, src.transform)
red = src.read(1, window=window)
print(f"read {red.shape} in {time.perf_counter() - start:.1f}s "
f"({red.size / (src.width * src.height):.2%} of the scene)")
scene: 10980 x 10980 px, EPSG:32630, uint16
internal tiles: (1024, 1024), overviews: [2, 4, 8, 16]
read (898, 787) in 5.7s (0.59% of the scene)
Six seconds and half a percent of the scene, over HTTP, with no file on disk. The GDAL_DISABLE_READDIR_ON_OPEN setting matters more than it looks: without it, GDAL lists the whole S3 prefix looking for sidecar files, which can take longer than the read itself.
Example 3 β a time series from the metadata alone
import pandas as pd
catalog = Client.open("https://earth-search.aws.element84.com/v1")
items = list(catalog.search(
collections=["sentinel-2-l2a"],
bbox=list(STUDY),
datetime="2026-01-01/2026-08-01",
).items())
frame = pd.DataFrame([{
"date": item.datetime.date(),
"cloud": round(item.properties["eo:cloud_cover"], 1),
"platform": item.properties["platform"],
"id": item.id,
} for item in items]).sort_values("date")
print(f"{len(frame)} scenes; {(frame['cloud'] < 20).sum()} usable")
print(frame.groupby(frame["date"].astype("datetime64[ns]").dt.month)
.agg(scenes=("id", "size"), usable=("cloud", lambda s: (s < 20).sum()))
.to_string())
44 scenes; 9 usable
scenes usable
date
1 6 0
2 5 1
3 6 1
4 5 0
5 6 2
6 5 2
7 6 3
8 5 0
Forty-four scenes, nine of them usable, all of that established from metadata in a couple of seconds. That monthly table is the honest answer to "can I do a monthly time series here?" β and for January and April, in this climate, the answer is no.
Explanation
Why STAC is just GeoJSON
A STAC Item is a valid GeoJSON Feature. Its geometry is the scene footprint and its properties hold the metadata, with a handful of required fields (datetime, id) and an assets object alongside.
The consequences are practical. Any GeoJSON tool can read an Item. A search response is a FeatureCollection, so it drops straight into GeoPandas:
gdf = gpd.GeoDataFrame.from_features(
[item.to_dict() for item in items], crs="EPSG:4326"
)
print(gdf[["datetime", "eo:cloud_cover"]].head(3).to_string(index=False))
Footprints become a layer you can plot, intersect and clip like any other. That reuse of an existing format is why STAC spread so quickly.
Why the API is a profile of OGC API Features
STAC's search API is a constrained version of the same standard covered in spatial web services explained β /collections, /collections/{id}/items, limit, bbox, paging by next link. It adds datetime and query on top.
So the truncation discipline from GeoJSON downloaded from an API is empty or truncated applies here too. search.matched() is the STAC name for numberMatched, and search.items() handles paging for you β which is precisely why you should use the client rather than hand-rolled requests.
Why COGs make the download step disappear
A Cloud-Optimized GeoTIFF is an ordinary GeoTIFF with two constraints: the data is stored in internal tiles rather than strips, and reduced-resolution overviews are included. The header sits at the front.
A client reads the header with one small range request, works out which tiles cover the window, and fetches only those. For the scene above that is 0.59% of the file. And because overviews are present, a client that only needs a preview can read the 16Γ overview instead β a few hundred kilobytes for a whole scene.
This is why STAC catalogues do not offer a "download" button. The file already behaves like a service.
Why product level matters more than the search
The commonest STAC mistake is not a bad query β it is searching the wrong collection. For Sentinel-2:
- L1C β top-of-atmosphere reflectance. What the sensor saw, including haze.
- L2A β surface reflectance, atmospherically corrected, with a scene classification band.
Compute an index like NDVI from L1C and the values are not comparable between dates, because the atmosphere differed. That is a silent error: the arithmetic works, the map looks plausible, and the time series is measuring weather.
Unless you are doing your own atmospheric correction, use L2A.
Edge cases or notes
bboxis always WGS84 degrees, even when the imagery is in UTM. Convert your study area before searching, not after.eo:cloud_coveris scene-wide. Use the scene classification or cloud mask asset for a per-pixel answer over your extent.- Asset keys are not standardised across collections. Sentinel-2 on Earth Search uses
red/nir; other catalogues useB04/B08. Inspectitem.assetsrather than assuming. - Some catalogues require signed URLs. Microsoft's Planetary Computer needs
planetary_computer.sign(item)before the asset hrefs work; Earth Search does not. - A scene footprint is not a coverage guarantee. Sentinel-2 granules include large nodata areas along orbit edges β a footprint can contain your area while the pixels there are empty.
datetimeis RFC 3339 and timezone-aware. A bare date is interpreted as UTC midnight, which can shift results by one scene near the boundary.- Static catalogues exist too. Not every STAC has a search API; some are just a tree of JSON files on object storage, which you crawl rather than query.
Internal links
- How to search and download satellite imagery with STAC in Python β the practical implementation
- The raster data model explained β dtype, NoData and transforms in what you download
- Spatial web services explained β the OGC standard STAC's API profiles
- GeoJSON downloaded from an API is empty or truncated β paging and truncation, which apply here too
- GIS data sources explained β where sensor data fits among the alternatives
- How to merge and mosaic rasters with rasterio β combining scenes when one does not cover you
- How to clip a raster to a polygon in Python β after the windowed read
- Digital elevation models explained β
cop-dem-glo-30is in the same catalogue
FAQ
What is the difference between a STAC Item and an Asset?
An Item is one scene's metadata β a GeoJSON Feature with a footprint and properties. Assets are the files hanging off it: individual bands, thumbnails, metadata documents.
Do I need an account?
For Earth Search and most public catalogues, no. Some β including Microsoft's Planetary Computer β need a token or a URL-signing step before assets are readable.
Why is bbox in degrees when the imagery is in UTM?
The specification fixes it that way, so one query works across catalogues whose data is in many different CRSs. Convert your study area to WGS84 before searching.
Can I search for elevation data, not just imagery?
Yes. cop-dem-glo-30 and cop-dem-glo-90 are STAC collections on the same catalogue and search identically.
Why should I use L2A rather than L1C?
L2A is atmospherically corrected surface reflectance. Indices computed from L1C are not comparable between dates because the atmosphere differed β a silent error in any time series.
Do I have to download the whole scene?
No, and that is the point. Assets are Cloud-Optimized GeoTIFFs, so a windowed read fetches only the tiles covering your area.
How do I know a scene actually covers my study area?
Intersect item.geometry with your area and compute the fraction. A bbox search returns anything that touches your box, including scenes clipping one corner.