Filtering STAC Items with CQL2
To express a filter the server can evaluate — rather than one your loop evaluates after the data has crossed the network — pass a CQL2 expression to the search:
from pystac_client import Client
catalog = Client.open("https://example-stac-api.org/v1")
search = catalog.search(
collections=["sentinel-2-l2a"],
bbox=[36.5, -1.6, 37.2, -1.0],
datetime="2023-06-01/2023-08-31",
filter_lang="cql2-json",
filter={
"op": "and",
"args": [
{"op": "<", "args": [{"property": "eo:cloud_cover"}, 20]},
{"op": "=", "args": [{"property": "s2:mgrs_tile"}, "36MYF"]},
],
},
)
print(search.matched(), "items match")
This is the expressive end of the query layer described in Querying STAC Catalogs Programmatically.
Why This Arises in Remote Sensing Workflows
Search results are the cheapest thing to shrink in a pipeline. Every item that crosses the network costs a fraction of a page transfer, and every item that reaches your loop costs whatever the loop does with it. A filter that runs on the server removes both costs; the same filter in Python removes neither.
The basic filters — date, bounding box, collection — have always been server-side. What CQL2 adds is composition: “cloud cover under 20 percent AND this tile, OR cloud cover under 5 percent from any tile”, “processing baseline in this set”, “not this platform”. Before CQL2 those had to be approximated with a broad server-side filter followed by client-side refinement, which is exactly the pattern that turns a 40-item search into a 4,000-item transfer.
There is a second, subtler benefit. A filter expressed as data can be stored, reviewed and reused. A filter expressed as an if inside a loop is invisible to everything except the person reading that function, and it drifts between the exploratory notebook and the production job.
Environment & Setup
| Package | Version | Why |
|---|---|---|
pystac-client |
≥0.7 | filter, filter_lang, get_queryables |
pystac |
≥1.8 | Item and collection models |
requests |
≥2.31 | Underlying HTTP, used for the queryables fetch |
pip install "pystac-client>=0.7" "pystac>=1.8"
Complete Working Example
This function discovers what the server will filter on, builds a CQL2 expression from a plain dictionary of conditions, drops anything the server cannot evaluate, and reports what it had to leave to the client.
from typing import Any
from pystac_client import Client
def queryable_properties(catalog: Client, collection: str) -> set[str]:
"""Property names the server will actually filter on."""
try:
schema = catalog.get_collection(collection).get_queryables()
except Exception:
return set()
return set(schema.get("properties", {}).keys())
def build_cql2(conditions: list[tuple[str, str, Any]], allowed: set[str]) -> tuple[dict, list]:
"""Turn (property, op, value) triples into one CQL2-JSON expression.
Returns the expression and the conditions that had to be dropped because the
server does not index those properties.
"""
args, dropped = [], []
for prop, op, value in conditions:
if allowed and prop not in allowed:
dropped.append((prop, op, value))
continue
if op == "in":
args.append({"op": "in", "args": [{"property": prop}, list(value)]})
elif op == "between":
lo, hi = value
args.append({"op": "between", "args": [{"property": prop}, lo, hi]})
elif op == "like":
args.append({"op": "like", "args": [{"property": prop}, value]})
else:
args.append({"op": op, "args": [{"property": prop}, value]})
if not args:
return {}, dropped
expr = args[0] if len(args) == 1 else {"op": "and", "args": args}
return expr, dropped
if __name__ == "__main__":
catalog = Client.open("https://example-stac-api.org/v1")
collection = "sentinel-2-l2a"
allowed = queryable_properties(catalog, collection)
conditions = [
("eo:cloud_cover", "<", 20),
("s2:mgrs_tile", "in", ["36MYF", "36MYG", "36NYF"]),
("s2:processing_baseline", ">=", "05.00"),
("view:sun_elevation", ">", 30),
]
expr, dropped = build_cql2(conditions, allowed)
if dropped:
print("not queryable server-side, refine in Python:", dropped)
search = catalog.search(
collections=[collection],
bbox=[36.5, -1.6, 37.2, -1.0],
datetime="2023-06-01/2023-08-31",
filter_lang="cql2-json",
filter=expr or None,
limit=100,
)
print("matched:", search.matched())
for item in search.items():
print(item.id, item.properties.get("eo:cloud_cover"), item.properties.get("s2:mgrs_tile"))
The dropped list is the important output. It makes the boundary between server-side and client-side filtering explicit, so nobody has to guess whether a condition was actually applied — the ambiguity that makes a silently-ignored filter so hard to notice.
Variant Patterns
1. Boolean composition the query extension cannot express
# "clear scenes anywhere, OR slightly cloudy scenes from the priority tile"
expr = {
"op": "or",
"args": [
{"op": "<", "args": [{"property": "eo:cloud_cover"}, 5]},
{
"op": "and",
"args": [
{"op": "<", "args": [{"property": "eo:cloud_cover"}, 30]},
{"op": "=", "args": [{"property": "s2:mgrs_tile"}, "36MYF"]},
],
},
],
}
That expression is a single request. The equivalent without CQL2 is two searches and a client-side union, which duplicates items that satisfy both branches and needs deduplication by item id.
2. The text form, for humans
CQL2 also has a text encoding, which is easier to read in a configuration file and to review in a pull request.
search = catalog.search(
collections=["sentinel-2-l2a"],
filter_lang="cql2-text",
filter="eo:cloud_cover < 20 AND s2:mgrs_tile IN ('36MYF','36MYG')",
)
Store filters as text in configuration, parse them to JSON at runtime if the server prefers it, and keep them out of the code — the same argument for externalising index definitions in Building a YAML-Driven Multi-Index Pipeline.
3. Confirming the filter was honoured
baseline = catalog.search(collections=[collection], bbox=bbox, datetime=window).matched()
filtered = search.matched()
if baseline is not None and filtered == baseline:
raise RuntimeError("filter appears to have been ignored by the server")
Reading the Queryables Document
The queryables endpoint returns a JSON Schema describing each filterable property, and it is worth reading rather than skimming. Three fields carry most of the information.
The property name is the exact string CQL2 expects, including its extension prefix. eo:cloud_cover and cloud_cover are different properties, and a filter using the wrong one is a filter on something that does not exist.
The declared type tells you what literal to send. A property typed as a string will not match an integer literal, which is the usual explanation for a filter that returns nothing despite obviously matching items being present — processing baselines are the classic example, since "05.00" and 5.0 are not the same value.
The enum, when present, is the complete set of values the server has indexed. It is the fastest way to discover the spelling of platform names, product levels and tile identifiers without paging through results to find out.
Catalogues differ in what they expose, so treat the document as a per-catalogue capability report rather than a specification — the portability point made in Querying STAC Catalogs Programmatically.
Common Errors
filter is accepted but nothing is filtered
The API does not advertise the CQL2 conformance class, or filter_lang was omitted so the filter was interpreted as the older query extension. Compare matched() against an unfiltered baseline.
A numeric comparison returns nothing
The property is typed as a string in the queryables document. Send a string literal, or drop that condition to the client side.
like matching does not behave as expected
CQL2 uses SQL-style wildcards — % for any sequence, _ for a single character — not shell globs. "S2A%" matches, "S2A*" does not.
Frequently Asked Questions
Q: How is CQL2 different from the query extension?
The query extension supports simple per-property comparisons. CQL2 adds boolean composition, negation, IN and BETWEEN, string pattern matching and spatial predicates, so filters that previously had to run client-side can run on the server.
Q: What happens if the server does not support CQL2?
Well-behaved APIs return an error naming the unsupported conformance class. Some ignore the filter and return everything, which is why checking matched() against an unfiltered baseline is worth the extra request.
Q: Can I filter on a property that is not queryable? No. Properties absent from the queryables endpoint are not indexed, and filtering on them either errors or is ignored. Filter server-side on what is queryable and refine the rest in Python.
Related
- Querying STAC Catalogs Programmatically — the parent topic covering search, pagination and item handling.
- Using pystac-client to Filter Sentinel-2 Imagery by Date — the temporal half of the same search.
- Paginating Large STAC Searches with pystac-client — what happens after the filter narrows the set.
- Scheduling Sentinel-2 Downloads with Prefect Flows — running these searches on a schedule.