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.

What the filter's location costs A compound condition evaluated on the server returns 38 items in one page. The same condition approximated by a broad server-side filter and refined in Python returns 2,400 items across 24 pages, of which 2,362 are discarded after transfer. Same condition, two places to evaluate it CQL2 on the server 38 items · 1 page · 0.4 s broad filter + Python loop 2,400 items transferred — 38 kept, 2,362 discarded 24 pages · 11 s · same answer The dark block is the useful result; everything pale was paid for and thrown away. At 40,000 scenes the ratio is unchanged — but the wasted transfer becomes the job's dominant cost.

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.

CQL2 operators worth knowing for imagery search Comparison operators cover thresholds on cloud cover and sun elevation. IN matches a tile list in one expression. BETWEEN bounds a numeric range. LIKE matches identifier patterns. AND, OR and NOT compose them, which is the capability the older query extension lacks. The operators that do the work operator example typical use < > = <> eo:cloud_cover < 20 thresholds on numeric properties in s2:mgrs_tile IN ('36MYF','36NYG') a tile list in one expression between view:sun_elevation BETWEEN 30 AND 70 bounded ranges like platform LIKE 'sentinel-2%' identifier patterns and / or / not compose the above what the query extension cannot do Check the queryables document first: an operator is only usable on an indexed property.
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

How servers respond to a filter they cannot evaluate A conforming API returns a 400 naming the unsupported conformance class. A stricter one may return zero items. The dangerous case is an API that ignores the filter and returns the unfiltered set, which looks like a successful query over a wide area. Detect the third case before it reaches production 400 Bad Request names the missing conformance class obvious, easy to handle zero items filter parsed but matched nothing, or was rejected noticeable, ambiguous filter ignored full result set returned with a 200 status silent, and expensive Compare matched() with and without the filter: if the counts are identical, the filter did nothing. One extra request per pipeline run, and it turns a silent failure into a loud one.
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.