Building a Static STAC Catalog with pystac

Build the Collections first, add Items to them, then call normalize_hrefs and save once at the end — pystac resolves link structure from the object graph at save time, so a catalogue assembled in that order writes correct relative links and one assembled Item-first does not.

A static STAC catalogue is a tree of JSON files with no runtime, which makes it the cheapest way to publish a searchable-by-convention asset index. The cost is that every relationship is expressed as a link, and links written by hand or at the wrong moment produce a tree that validates file by file and cannot be traversed. This guide sits under STAC catalog metadata automation, within Automated Metadata Generation & Schema Mapping.

Build order for a static catalogueThe root catalogue and collections are created first, items are added, extents are updated, and hrefs are normalised and saved in one final pass.Root Catalogid, descriptionaddCollectionslicence, providersaddItemsone per assetupdateExtentsfrom the Items presentsavenormalize + saveone pass, at the end
Normalising before every Item is present writes links to files that do not exist yet.

Automated Python Implementation

#!/usr/bin/env python3
"""Assemble a static STAC catalogue from a directory of COGs."""
import datetime
import pathlib

import pystac
import rasterio
from rasterio.warp import transform_bounds
from shapely.geometry import box, mapping

def item_from_cog(path):
    """One Item per raster, with a footprint in EPSG:4326."""
    with rasterio.open(path) as src:
        bounds = transform_bounds(src.crs, "EPSG:4326", *src.bounds, densify_pts=21)
        acquired = src.tags().get("TIFFTAG_DATETIME")

    geometry = box(*bounds)
    when = (datetime.datetime.strptime(acquired, "%Y:%m:%d %H:%M:%S")
            if acquired else
            datetime.datetime.fromtimestamp(path.stat().st_mtime, datetime.timezone.utc))

    item = pystac.Item(
        id=path.stem,
        geometry=mapping(geometry),
        bbox=list(bounds),
        datetime=when,
        properties={"datetime_source": "header" if acquired else "file_mtime"},
    )
    item.add_asset("data", pystac.Asset(
        href=str(path.resolve()),
        media_type=pystac.MediaType.COG,
        roles=["data"],
    ))
    return item

def build_catalog(root_dir, out_dir, collection_id, licence="CC-BY-4.0"):
    catalog = pystac.Catalog(
        id="imagery-catalog",
        description="Static catalogue of orthoimagery assets.",
    )

    collection = pystac.Collection(
        id=collection_id,
        description="Orthoimagery, tiled and published as cloud-optimised GeoTIFFs.",
        extent=pystac.Extent(
            spatial=pystac.SpatialExtent([[-180.0, -90.0, 180.0, 90.0]]),
            temporal=pystac.TemporalExtent([[None, None]]),
        ),
        license=licence,
    )
    catalog.add_child(collection)

    items = [item_from_cog(p) for p in sorted(pathlib.Path(root_dir).rglob("*.tif"))]
    if not items:
        raise SystemExit(f"no rasters found under {root_dir}")
    for item in items:
        collection.add_item(item)

    # Extents must reflect the Items actually present, so update after adding.
    collection.extent = pystac.Extent.from_items(items)

    catalog.normalize_hrefs(str(out_dir))
    catalog.save(catalog_type=pystac.CatalogType.SELF_CONTAINED)
    return catalog

Three lines carry disproportionate weight. Extent.from_items recomputes the spatial and temporal envelope from what was actually added, replacing the world-extent placeholder the Collection was created with — a placeholder that, left in place, makes every spatial query match the Collection. normalize_hrefs assigns every object a path derived from the tree structure, which is why it must run after the last Item is added. And SELF_CONTAINED writes relative structural links, producing a tree that can be moved or copied without rewriting.

The three pystac catalog typesSelf-contained, absolute-published and relative-published catalogue types compared by link style and what each survives.Structural linksSurvives a moveItem usable aloneSELF_CONTAINEDrelativeyesneeds the treeABSOLUTE_PUBLISHEDabsolutenoyesRELATIVE_PUBLISHEDrelative, with absolute selfyesyes
Self-contained is the right default; absolute is for a catalogue that will never move.

Validation and Pipeline Integration

Validate the objects, then walk the tree — the second check is the one that finds broken structure.

python -c "
import pystac
cat = pystac.Catalog.from_file('out/catalog.json')
cat.validate_all()
print(sum(1 for _ in cat.get_items(recursive=True)), 'items reachable')
"
def test_collection_extent_is_not_the_placeholder():
    catalog = build_catalog("fixtures/cogs", "out", "ortho")
    collection = next(catalog.get_children())
    bbox = collection.extent.spatial.bboxes[0]
    assert bbox != [-180.0, -90.0, 180.0, 90.0]

def test_every_item_is_reachable_from_the_root():
    catalog = pystac.Catalog.from_file("out/catalog.json")
    ids = {item.id for item in catalog.get_items(recursive=True)}
    assert len(ids) == len(list(catalog.get_items(recursive=True)))

validate_all checks each object against its schema and says nothing about whether the tree hangs together; an Item saved without being added to a Collection validates perfectly and is unreachable. Counting reachable Items against expected Items is the assertion that catches it.

Regenerating Without Breaking Consumers

A static catalogue is usually rebuilt from scratch, which is simple and has one consequence worth planning for: every file is rewritten, so every consumer sees everything as changed.

For a small catalogue this is irrelevant. Past a few thousand Items it starts to matter, in two ways. Object storage charges per write, and rewriting a hundred thousand unchanged Items nightly is a bill for no benefit. More importantly, consumers that poll for changes — a harvester using modification times, a mirror using a sync tool — will re-fetch the entire catalogue on every rebuild.

Two mitigations are worth the effort at that scale. Write only what changed by comparing the serialised JSON of each object against what is already at its href, and skipping identical content. This preserves modification times for unchanged Items and reduces a full rebuild to the actual delta. Keep Item ids stable and derived from the asset, not from a counter or an ingestion order, so that a rebuild produces the same href for the same asset. An Item whose id changes between builds appears to consumers as a deletion and an unrelated addition.

Neither is needed on day one, and both are considerably cheaper to adopt before a catalogue has external consumers than after.

Where the Tree Structure Should Come From

pystac will happily hold every Item as a direct child of one Collection, and for a few hundred assets that is the right answer. Past a few thousand, the Collection’s own JSON file becomes a list of thousands of links that every consumer must download to reach any single Item, and the catalogue’s cost profile inverts: the index becomes larger than the thing being indexed.

Three axes to divide a catalogue onTime, space and product compared as sub-catalogue axes by the query pattern each serves and where each fails.ServesFails whenBy timequeries for recent dataconsumers filter by area onlyBy spacequeries for an areaextents overlap — one parent onlyBy productqueries by sensor or levelbetter as separate CollectionsBy ingestion batchnothing a consumer asksalways
Divide on what consumers filter by, not on what the pipeline happens to know.

Sub-catalogues fix this, and the axis to divide on is the one consumers filter by. Three are common and they are not interchangeable.

By time — year, then month — suits archives that grow continuously and are queried by recency. A consumer wanting last month’s imagery fetches two small files instead of one enormous one. It is the default for anything with an acquisition date.

By space — tile, grid cell or administrative area — suits catalogues queried by location where the extents are disjoint. It fails badly when extents overlap, because an Item legitimately belongs in several branches and pystac tolerates only one parent.

By product — sensor, processing level, resolution — suits catalogues whose consumers know what kind of thing they want before they know where or when. It is usually better expressed as separate Collections than as sub-catalogues, because those distinctions typically come with different licences and providers too.

The mistake to avoid is dividing on something the pipeline knows and consumers do not, such as the ingestion batch. It produces a tidy tree that no query pattern can exploit, and moving Items afterwards changes their hrefs — which, for consumers who bookmarked them, is indistinguishable from deletion.

Long-Term Compliance Best Practices

  • Set the Collection licence, not the Item licence, when they share terms. Items inherit it, and repeating it per Item guarantees divergence.
  • Record where the datetime came from. A property naming the source distinguishes an acquisition time from a file modification time, which no consumer can otherwise tell apart.
  • Recompute extents on every build. A Collection extent narrower than its Items hides them from search; wider, and it advertises coverage that does not exist.
  • Keep asset hrefs absolute and structural links relative. The tree then moves cleanly while individual Items remain usable when lifted out of it.
  • Validate after saving, not before. Link structure is only correct once normalize_hrefs has run, so validating the in-memory graph checks something else.