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

Same scene, different names A single Sentinel-2 acquisition over tile 36NYF on 14 June is catalogued by two providers. The collection names differ, the item ids differ in format, and the asset keys differ. The platform, tile and acquisition timestamp are identical, which is what a merge must key on. What differs, and what does not provider A collection: sentinel-2-l2a id: S2B_36NYF_20260614_0_L2A asset: "red" platform sentinel-2b · tile 36NYF 2026-06-14T08:02:11Z provider B collection: S2_L2A id: S2B_MSIL2A_20260614T080211_… asset: "B04" platform sentinel-2b · tile 36NYF 2026-06-14T08:02:11Z Red: provider-specific naming. Green: the physical facts that identify the acquisition. Key the merge on green, and map asset names to a common vocabulary on the way in.

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

Primary first, fallback fills the gaps A month of acquisitions is shown as a row of dates. Most come from the primary provider. Two dates the primary is missing — a recent acquisition not yet ingested and one gap from an outage — are filled from the fallback. Where both have the acquisition, the primary's item is kept. One month, merged outage gap not yet ingested primary provider fallback provider Record the provider on every item so any difference between them can be traced later.

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

Three checks on the merged list The merged list should contain no two items with the same acquisition key, every item should expose the common asset names downstream code expects, and the provider report should show which catalogs answered. A merged count equal to the sum of both providers' counts means deduplication did nothing. After merging unique keys no duplicate acquisitions common assets "red", "nir", "scl" present provider report who answered, who failed A merged count equal to the sum of the inputs means the key matched nothing.
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.