Searching Multiple STAC APIs and Merging Results
Search each catalog, then deduplicate on a key built from the acquisition itself rather than from item ids:
def acquisition_key(item) -> tuple:
p = item.properties
return (p.get("platform", "").lower(),
p.get("s2:mgrs_tile") or p.get("mgrs:utm_zone"),
item.datetime.replace(microsecond=0).isoformat())
merged = {}
for provider in ("primary", "fallback"): # priority order
for item in results[provider]:
merged.setdefault(acquisition_key(item), item) # first provider wins
The same acquisition has a different id in every catalog, so id-based deduplication silently keeps duplicates. This page belongs to querying STAC catalogs programmatically in Core Raster Fundamentals & STAC Mapping.
One Acquisition, Several Records
Asset naming is the second source of friction. One provider calls the red band red, another B04, a third B4. A merged item list is only useful if downstream code can ask for “red” regardless of provider, so normalising asset keys belongs in the same step as deduplication.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
pystac-client |
>=0.7 |
Searching each API |
pystac |
>=1.10 |
Item objects and asset manipulation |
concurrent.futures |
stdlib | Running searches in parallel |
pip install "pystac-client>=0.7" "pystac>=1.10"
Complete Working Example
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
import pystac_client
@dataclass(frozen=True)
class Provider:
name: str
url: str
collection: str
asset_map: dict[str, str] # provider asset key -> common name
PROVIDERS = [
Provider("earth-search", "https://earth-search.aws.element84.com/v1",
"sentinel-2-l2a", {"red": "red", "nir": "nir", "scl": "scl"}),
Provider("planetary", "https://planetarycomputer.microsoft.com/api/stac/v1",
"sentinel-2-l2a", {"B04": "red", "B08": "nir", "SCL": "scl"}),
]
def search_one(p: Provider, bbox, datetime, max_cloud: float):
try:
client = pystac_client.Client.open(p.url)
items = list(client.search(collections=[p.collection], bbox=bbox,
datetime=datetime,
query={"eo:cloud_cover": {"lt": max_cloud}}).items())
return p, items, None
except Exception as exc: # one failing API must not sink the run
return p, [], exc
def acquisition_key(item) -> tuple:
props = item.properties
tile = (props.get("s2:mgrs_tile") or props.get("mgrs:grid_square")
or props.get("landsat:wrs_path"))
return (props.get("platform", "").lower(), str(tile),
item.datetime.replace(microsecond=0).isoformat())
def normalise_assets(item, asset_map: dict[str, str]):
for provider_key, common in asset_map.items():
if provider_key in item.assets and common not in item.assets:
item.assets[common] = item.assets[provider_key]
item.properties["source_provider"] = item.properties.get("source_provider", "")
return item
def merged_search(bbox, datetime, *, max_cloud: float = 30.0):
with ThreadPoolExecutor(max_workers=len(PROVIDERS)) as pool:
results = list(pool.map(lambda p: search_one(p, bbox, datetime, max_cloud), PROVIDERS))
merged, report = {}, {}
for provider, items, error in results: # list order = priority
report[provider.name] = "error: " + str(error) if error else f"{len(items)} items"
for item in items:
item = normalise_assets(item, provider.asset_map)
item.properties["source_provider"] = provider.name
merged.setdefault(acquisition_key(item), item)
return sorted(merged.values(), key=lambda it: it.datetime), report
Catching exceptions per provider is the resilience half of the design. A pipeline that queries two catalogs should degrade to one when the other is down, not fail entirely; the report records which providers answered, so a run that silently fell back is visible afterwards. Pagination against each API follows the patterns in paginating large STAC searches with pystac-client.
Choosing a Provider Preference
Priority is a real decision, not an implementation detail. Choose the primary from the properties that matter most for the analysis — the processing baseline you want, the region the assets live in relative to your compute, and the format they are served in — and let the others fill gaps only. Mixing providers freely within one time series risks mixing processing versions, which reappears as apparent change; the per-scene radiometry discipline in converting int16 reflectance to float safely is what keeps that safe when mixing is unavoidable.
Keeping the Merge Honest
Two properties of the acquisition key deserve care. Timestamps must be rounded consistently, because one provider may report 08:02:11.024Z and another 08:02:11Z; rounding to the second is almost always enough. And the tile identifier must come from whichever property each provider uses — MGRS tile for Sentinel-2, WRS path and row for Landsat — so the key function should know each provider’s vocabulary rather than guessing.
It is also worth recording, on every merged item, which provider it came from and whether another provider had the same acquisition. That turns a later question — “why does this date look different?” — from an investigation into a lookup, and it is cheap to store in the item properties alongside everything else described in attaching model metadata to STAC items.
Verification
items, report = merged_search([34.5, 0.2, 35.0, 0.6], "2026-06-01/2026-06-30")
print(report)
keys = [acquisition_key(i) for i in items]
assert len(keys) == len(set(keys)), "duplicate acquisitions survived the merge"
for i in items:
assert {"red", "nir", "scl"} <= set(i.assets), f"{i.id} lacks common assets"
If the merged count equals the sum of both providers’ counts, the key matched nothing — usually a timestamp rounding difference or a tile property that one provider names differently. Print two items that should have merged side by side and the mismatch will be obvious.
Common Errors
The merged list has obvious duplicates
The acquisition key differs between providers, usually in timestamp precision. Round timestamps to the second.
Downstream code raises KeyError: 'red'
An item from the fallback provider was not normalised. Apply the asset map to every item before merging.
One provider always wins even where the other is better
Priority is list order. Reorder the providers, or choose per item on a property such as processing baseline.
The whole search fails when one API is down
Exceptions were not caught per provider. Search each in isolation and record failures in a report.
Frequently Asked Questions
Q: Why would I search more than one catalog? For resilience, coverage and cost. One provider may be missing recent acquisitions, another may host assets in a cheaper region, and a pipeline that can fall back to a second catalog survives an outage of the first.
Q: Can I deduplicate by item id? Rarely. Providers name the same acquisition differently, so identical scenes have different ids. Deduplicate on the physical properties — platform, tile or path and row, and acquisition time rounded to the second — which are the same everywhere.
Q: Are the assets from different providers interchangeable? Not necessarily. Providers may serve different processing baselines, different formats, or reprocessed versions of the same acquisition. Check the processing properties before treating two items as the same product, and prefer one provider consistently for a single analysis.
Q: Do some providers need signed asset URLs? Yes — some catalogs return asset links that must be signed with a short-lived token before they can be read. Sign them as part of normalisation, per provider, so downstream readers see ordinary working URLs regardless of source.
Related
- Querying STAC Catalogs Programmatically — the parent topic.
- Filtering STAC Items with CQL2 — expressing the shared query each provider receives.
- Loading STAC Items into an xarray Cube with stackstac — turning the merged list into an array.
- Scheduling Sentinel-2 Downloads with Prefect Flows — running the merged search on a schedule.