STAC Catalog Publishing for Raster Archives

The SpatioTemporal Asset Catalog (STAC) specification defines a lightweight, link-driven JSON model for describing georeferenced assets — most commonly imagery and other raster archives — so they can be discovered, filtered, and streamed without a heavyweight catalogue server. For teams sitting on terabytes of GeoTIFFs, STAC turns an opaque bucket of files into a crawlable graph of metadata that both machines and humans can navigate. This page covers the STAC object model, the extensions that make raster metadata self-describing, the choice between static catalogs and a dynamic STAC API, a complete pystac implementation that builds a catalogue from a directory of GeoTIFFs, error handling, schema validation, and the performance decisions that separate a demo from a petabyte archive.

Prerequisites & Architecture Context

Before publishing a STAC catalogue, engineering teams should be comfortable with:

  • GeoJSON geometry and bounding boxes — a STAC Item is a GeoJSON Feature, so geometry, bbox, and coordinate ordering (always longitude, latitude in GeoJSON) must be second nature
  • Cloud-Optimized GeoTIFF (COG) internals: tiled layout, internal overviews, and HTTP range requests — STAC describes COGs but the reader streams them directly
  • Coordinate reference system codes and how to read a raster’s CRS, transform, and native bounds with rasterio
  • Python packages: pystac>=1.10 (the object model), rasterio>=1.3 (reading raster bounds and CRS), shapely>=2.0 (footprint geometry), and optionally stac-validator for standalone validation

STAC is one member of a wider family of catalogue and metadata standards described in Spatial Metadata & Catalog Integration. It is deliberately narrower than the general-purpose data-portal vocabularies: where DCAT-AP for Spatial Data Portals describes datasets for open-data harvesting and Schema Validation for Spatial Records governs how any spatial record is checked for correctness, STAC is purpose-built for spatiotemporal assets and pins every field to a published JSON Schema. Because STAC assets are typically COGs, the same archive can also be exposed as a dynamic coverage service; the WCS Coverage Service Fundamentals guide covers serving raster subsets on demand, which pairs naturally with STAC-driven discovery.

STAC Catalog to Collection to Item to Asset Hierarchy Tree diagram showing a root Catalog at the top linking down to two Collections, each Collection linking to Items, and each Item referencing a Cloud-Optimized GeoTIFF asset. Link relation types root, parent, child, item, and self are annotated on the edges. Catalog (root) id, description, links[] Collection extent, licence, providers Collection extent, licence, providers Item (GeoJSON Feature) bbox, geometry, datetime Item (GeoJSON Feature) bbox, geometry, datetime asset: scene.tif (COG) asset: scene.tif (COG) rel: child rel: child rel: item rel: item every object also carries rel: root, rel: parent, rel: self

Specification Deep-Dive: The STAC Object Model

STAC is built from three object types plus the Asset and Link sub-objects. Every object is plain JSON that declares a stac_version and, when it uses optional fields, a stac_extensions array of schema URIs.

Catalog — the crawlable entry point

A Catalog is the lightweight root of the graph. It carries only an id, a description, a stac_version, and a links array. It has no spatial or temporal metadata of its own — its sole job is to be an entry point that a crawler can fetch first and then walk outward by following child and item links. A catalogue may nest arbitrarily: sub-catalogs group Collections or Items by any dimension you choose (year, region, sensor).

Collection — grouped assets with extent and licence

A Collection extends Catalog with the fields needed to describe a homogeneous set of assets: a spatial and temporal extent, a license (an SPDX identifier such as CC-BY-4.0), providers, keywords, and optional summaries that roll up the range of property values across its Items. The extent is the union of every child Item’s footprint and time span; consumers read it to decide whether a Collection is worth crawling at all.

Item — a GeoJSON Feature with spatiotemporal metadata

The Item is where the real metadata lives, and its most important design decision is that an Item is a valid GeoJSON Feature. That means any GeoJSON tool can read it. On top of the base Feature, STAC mandates a specific structure:

Field Location Meaning
type root Always Feature
bbox root Bounding box [west, south, east, north] in longitude, latitude
geometry root GeoJSON footprint polygon (the true scene outline, not just the bbox)
properties.datetime properties RFC 3339 acquisition time, or null with a start/end range
properties.start_datetime / end_datetime properties Temporal range when a single instant is inappropriate
assets root Map of named Asset objects — the actual downloadable files
links root root, parent, self, and collection relation links
collection root The id of the owning Collection

Asset — pointing at the Cloud-Optimized GeoTIFF

An Asset is a named entry inside an Item’s assets map. Each Asset has an href, a type (media type), an optional human title, and a roles array. For a COG the media type is image/tiff; application=geotiff; profile=cloud-optimized and the conventional role is data; a downsampled preview would use role thumbnail or overview. Because a COG supports HTTP range requests, a client that reads the STAC Item can stream a single tile out of a multi-gigabyte file without downloading it whole.

Every STAC object is connected by links, each with a rel (relation) type that defines the graph:

rel value Points to Present on
root The catalogue’s top Catalog Every object
self The object’s own canonical URL Objects with an absolute location
parent The immediate owning object Collections and Items
child A sub-Catalog or Collection Catalogs and Collections
item A member Item Collections and Catalogs
collection The owning Collection Items

A crawler enters at root, fans out through child links to Collections, then follows item links to Items — never needing a database. This is the whole trick behind a static STAC catalog: a tree of JSON files on object storage that is fully navigable by link-following alone.

Static catalog vs dynamic STAC API

A static catalog is those JSON files sitting on S3, Cloudflare R2, or any static host. It is cheap, has no moving parts, and is crawled by following links. Its limitation is filtering: to answer “every scene over Berlin in June” a client must walk the whole tree.

A STAC API is a dynamic service that solves this by adding a /search endpoint accepting bbox, datetime, collections, and CQL filters, returning a GeoJSON FeatureCollection of matching Items. The STAC API specification is a profile of OGC API - Features — its /collections and /collections/{id}/items routes and its conformance declaration are the OGC API - Features building blocks with STAC’s asset semantics layered on top. Choose static for archival publishing where consumers crawl once and index; choose an API when interactive, ad-hoc filtering over a large, changing archive is the primary access pattern.

The proj, eo, and raster extensions

STAC’s core is deliberately minimal; domain metadata lives in extensions, each with its own JSON Schema listed in stac_extensions:

  • proj (projection) — records the asset’s native CRS via proj:epsg (or proj:wkt2/proj:projjson), plus proj:shape, proj:transform, and proj:bbox. This lets a client reproject or align tiles without opening the file.
  • eo (electro-optical) — describes optical eo:bands (name, common name, centre wavelength) and scene-level eo:cloud_cover, the field most imagery searches filter on.
  • raster — describes each band’s raster:bands with data_type, nodata, spatial_resolution, and scale/offset, so a numeric consumer can interpret pixel values correctly.

Python Implementation

The script below builds a complete Catalog → Collection → Item hierarchy from a directory of GeoTIFFs using pystac and rasterio, applies the proj, eo, and raster extensions, and writes a self-contained static catalogue. It is runnable against any folder of COGs.

"""
build_stac_catalog.py — publish a directory of GeoTIFFs as a static STAC catalog.

Dependencies (pip install):
    pystac[validation]>=1.10, rasterio>=1.3, shapely>=2.0
"""
from __future__ import annotations

import datetime as dt
from pathlib import Path

import pystac
import rasterio
from pystac.extensions.eo import Band, EOExtension
from pystac.extensions.projection import ProjectionExtension
from pystac.extensions.raster import DataType, RasterBand, RasterExtension
from rasterio.warp import transform_bounds
from shapely.geometry import box, mapping

COG_MEDIA_TYPE = "image/tiff; application=geotiff; profile=cloud-optimized"


def raster_footprint(src: rasterio.DatasetReader) -> tuple[list[float], dict]:
    """Return (bbox, geometry) in EPSG:4326 for a raster's native bounds."""
    # transform_bounds densifies the edge so a reprojected footprint is accurate
    west, south, east, north = transform_bounds(
        src.crs, "EPSG:4326", *src.bounds, densify_pts=21
    )
    bbox = [west, south, east, north]
    geometry = mapping(box(west, south, east, north))
    return bbox, geometry


def make_item(cog_path: Path, acquired: dt.datetime) -> pystac.Item:
    """Build a single STAC Item from one Cloud-Optimized GeoTIFF."""
    with rasterio.open(cog_path) as src:
        bbox, geometry = raster_footprint(src)
        native_epsg = src.crs.to_epsg()
        shape = [src.height, src.width]
        transform = list(src.transform)[:6]
        band_dtype = src.dtypes[0]
        nodata = src.nodata

    item = pystac.Item(
        id=cog_path.stem,
        geometry=geometry,
        bbox=bbox,
        datetime=acquired,
        properties={},
    )

    # --- projection extension: native CRS + pixel grid -------------------
    proj = ProjectionExtension.ext(item, add_if_missing=True)
    proj.epsg = native_epsg
    proj.shape = shape
    proj.transform = transform

    # --- the COG asset itself -------------------------------------------
    asset = pystac.Asset(
        href=str(cog_path.resolve()),
        media_type=COG_MEDIA_TYPE,
        roles=["data"],
        title="Cloud-Optimized GeoTIFF",
    )
    item.add_asset("image", asset)

    # --- eo + raster extensions describe the band on the asset ----------
    eo = EOExtension.ext(asset, add_if_missing=True)
    eo.bands = [Band.create(name="b1", common_name="pan")]

    rb = RasterBand.create(
        data_type=DataType(band_dtype),
        nodata=nodata,
        spatial_resolution=abs(transform[0]),
    )
    RasterExtension.ext(asset, add_if_missing=True).bands = [rb]

    return item


def build_catalog(cog_dir: Path, out_dir: Path) -> pystac.Catalog:
    """Assemble Catalog -> Collection -> Items from a folder of COGs."""
    catalog = pystac.Catalog(
        id="raster-archive",
        description="Static STAC catalog of a raster archive.",
    )

    cogs = sorted(cog_dir.glob("*.tif"))
    if not cogs:
        raise FileNotFoundError(f"No .tif files found in {cog_dir}")

    # Placeholder extent — pystac fills the real union from Items below.
    extent = pystac.Extent(
        spatial=pystac.SpatialExtent([[-180.0, -90.0, 180.0, 90.0]]),
        temporal=pystac.TemporalExtent([[None, None]]),
    )
    collection = pystac.Collection(
        id="scenes",
        description="Raster scenes published as Cloud-Optimized GeoTIFFs.",
        extent=extent,
        license="CC-BY-4.0",
    )
    catalog.add_child(collection)

    for i, cog in enumerate(cogs):
        # Substitute a real acquisition time from a sidecar or filename.
        acquired = dt.datetime(2024, 6, 1, tzinfo=dt.timezone.utc) + dt.timedelta(days=i)
        collection.add_item(make_item(cog, acquired))

    # Recompute the Collection extent as the true union of Item footprints.
    collection.update_extent_from_items()

    # normalize_hrefs rewrites every link into a consistent relative layout.
    catalog.normalize_hrefs(str(out_dir))
    catalog.save(catalog_type=pystac.CatalogType.SELF_CONTAINED)
    return catalog


if __name__ == "__main__":
    import sys

    src = Path(sys.argv[1] if len(sys.argv) > 1 else "./cogs")
    dst = Path(sys.argv[2] if len(sys.argv) > 2 else "./stac")
    cat = build_catalog(src, dst)
    print(f"Wrote catalog with {len(list(cat.get_items(recursive=True)))} items to {dst}")

Annotated Walkthrough

Footprint reprojection (raster_footprint) — A raster’s native bounds are almost never in EPSG:4326, but STAC bbox and geometry must be. rasterio.warp.transform_bounds with densify_pts=21 reprojects the corners and interpolates points along each edge, so a scene in a projected CRS does not collapse to a skewed rectangle. The resulting box is a simple rectangular footprint; for irregular scenes you would derive the true polygon from the raster’s valid-data mask instead.

Item construction (make_item) — The pystac.Item constructor takes geometry, bbox, datetime, and an empty properties dict. Passing a timezone-aware datetime is important: pystac serialises it to RFC 3339 with an explicit offset, and a naive datetime would produce an ambiguous timestamp that some validators reject.

Projection extensionProjectionExtension.ext(item, add_if_missing=True) both registers the extension’s schema URI in stac_extensions and returns a typed accessor. Setting proj.epsg, proj.shape, and proj.transform records the native pixel grid, letting a downstream reader align tiles without opening the COG.

Asset and roles — The Asset’s media_type is the exact COG profile string, and roles=["data"] marks it as the primary payload rather than a thumbnail or metadata sidecar. The asset key ("image") is the map key clients use to request a specific asset by name.

eo and raster on the asset, not the Item — Band metadata is attached to the Asset, because different assets in one Item (e.g. a visual COG and a separate elevation COG) carry different bands. EOExtension.ext(asset, ...) and RasterExtension.ext(asset, ...) write eo:bands and raster:bands into the asset object.

Extent recomputation — The Collection starts with a placeholder whole-world extent; collection.update_extent_from_items() replaces it with the true union of every Item’s bbox and datetime after the Items are added. Publishing the placeholder would mislead crawlers into fetching a Collection that does not cover their area of interest.

normalize_hrefs + savenormalize_hrefs walks the tree and assigns every Catalog, Collection, and Item a consistent relative path derived from its id; save with CatalogType.SELF_CONTAINED then writes purely relative links so the entire ./stac folder can be uploaded to any host and still resolve.

Error Handling & Edge Cases

Missing datetime — The single most common failure. If a source raster has no acquisition time and you construct pystac.Item(datetime=None) without also setting start_datetime and end_datetime in properties, validation fails because a temporal reference is mandatory. Detect this at ingest: if no timestamp is available, pass an explicit range, and never silently default to datetime.now(), which stamps the catalogue with meaningless publish times.

Invalid geometry or bbox — The bbox must be [west, south, east, north] with west < east and south < north, and it must correspond to the geometry. A frequent bug is swapping axis order: GeoJSON is always longitude-first, so a footprint computed as latitude-first produces a geometry that plots off the coast of Africa. After building each Item, assert bbox[0] < bbox[2] and bbox[1] < bbox[3] and that the geometry’s bounds match the bbox within tolerance.

Absolute vs relative hrefs — Saving a SELF_CONTAINED catalogue but leaving asset hrefs as absolute local filesystem paths (as the script does with cog_path.resolve()) makes the catalogue non-portable — the JSON references /home/user/cogs/scene.tif, which no remote client can fetch. For a published archive, set asset hrefs to the final object-storage URLs, or use CatalogType.ABSOLUTE_PUBLISHED with a real base URL, so normalize_hrefs pins every link to that host.

Antimeridian-crossing scenes — A scene spanning 180° longitude produces a bbox where west > east. The STAC spec handles this with a four-element-per-corner convention, but many naive readers break. Split such footprints into a MultiPolygon at the antimeridian rather than emitting a bbox that appears to wrap the entire globe.

Nodata and dtype mismatches — If src.nodata is None but the raster genuinely uses a sentinel value, downstream numeric consumers will treat fill pixels as data. Resolve the true nodata at ingest and record it in raster:bands; do not assume 0.

Testing & Compliance Verification

Every STAC object declares a stac_version and a set of stac_extensions, and each has a published JSON Schema. Validate against those schemas both in unit tests and in CI.

"""
test_stac_catalog.py — validate a built catalog against the STAC JSON Schemas.
"""
import datetime as dt

import pystac
import pytest
from build_stac_catalog import make_item, COG_MEDIA_TYPE


def test_item_validates_against_schemas(tmp_path):
    # A tiny in-memory Item with a known-good geometry/bbox/datetime.
    item = pystac.Item(
        id="scene-001",
        geometry={
            "type": "Polygon",
            "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
        },
        bbox=[0, 0, 1, 1],
        datetime=dt.datetime(2024, 6, 1, tzinfo=dt.timezone.utc),
        properties={},
    )
    item.add_asset(
        "image",
        pystac.Asset(href="./scene.tif", media_type=COG_MEDIA_TYPE, roles=["data"]),
    )
    # Raises pystac.validation.STACValidationError on any schema breach.
    assert item.validate() == [] or item.validate() is not None


def test_missing_datetime_is_rejected():
    with pytest.raises(Exception):
        bad = pystac.Item(
            id="no-time",
            geometry={"type": "Point", "coordinates": [0, 0]},
            bbox=[0, 0, 0, 0],
            datetime=None,          # no start/end range either
            properties={},
        )
        bad.validate()


def test_bbox_axis_order_is_lon_lat():
    item = pystac.Item(
        id="axis",
        geometry={"type": "Polygon",
                  "coordinates": [[[10, 50], [11, 50], [11, 51], [10, 51], [10, 50]]]},
        bbox=[10, 50, 11, 51],
        datetime=dt.datetime(2024, 6, 1, tzinfo=dt.timezone.utc),
        properties={},
    )
    assert item.bbox[0] < item.bbox[2]  # west < east
    assert item.bbox[1] < item.bbox[3]  # south < north

item.validate() fetches the core Item schema plus every schema URI in stac_extensions for the declared stac_version and reports the failing JSON pointer. For catalogue-wide checks outside Python, the standalone stac-validator CLI (stac-validator ./stac/catalog.json --recursive) crawls the whole tree and validates each object. Both resolve schemas over HTTP by default; in CI, pre-fetch the schemas and point the validator at a local cache so a build never fails because schemas.stacspec.org is briefly unreachable. There is no formal OGC CITE suite for static STAC, but a STAC API can additionally be checked against the OGC API - Features conformance classes it declares.

Performance & Scaling Notes

Static catalogs on S3 / R2 — For archival publishing, a static catalogue on object storage is the cheapest durable option: no compute, no database, and CDN-cacheable JSON. Set long Cache-Control TTLs on the JSON objects, and store the COGs in the same bucket so range reads stay in-region. Because every link is relative in a self-contained catalogue, a full copy to a new bucket needs no rewriting.

Sharded collections — A Collection whose item links all live in one JSON file becomes slow once it holds a few thousand Items — a crawler must parse a huge file to reach any single scene. Introduce intermediate sub-catalogs keyed by a natural partition (year/month, MGRS tile, or region) so no single JSON object carries more than a few hundred links. This keeps every fetch small and lets a crawler prune whole branches by their extent.

COG overviews — The performance of reading the archive depends on the COGs, not the STAC JSON. Ensure every GeoTIFF is a true COG with internal tiling and overviews (pyramids); a client zooming out then reads a decimated overview via a small range request instead of streaming full-resolution pixels. A STAC catalogue over non-cloud-optimised TIFFs advertises assets that are painful to stream. The same COGs can be fronted by a dynamic coverage endpoint — the WCS Coverage Service Fundamentals guide covers serving on-the-fly subsets, complementing STAC’s role as the discovery layer.

Dynamic search at scale — Past a few hundred thousand Items, link-crawling for filtered queries becomes untenable and a STAC API backed by a spatial database (typically PostGIS with a GiST index on footprint and a b-tree on datetime) is the right tool. The static catalogue can still be generated as a durable, self-describing source of truth and bulk-loaded into the API’s database.

Gotchas / Frequently Asked Questions

What is the difference between a static STAC catalog and a STAC API?

A static catalog is a set of JSON files on object storage crawled by following child and item links — no query engine, no compute, trivially CDN-cacheable. A STAC API is a dynamic service that adds a /search endpoint and conforms to OGC API - Features, letting clients filter by bbox, datetime, and collections without downloading the whole tree. Use static catalogs for cheap archival publishing where consumers crawl once and build their own index; use an API when interactive, ad-hoc filtering over a large or changing archive is the primary access pattern.

Do STAC Item hrefs have to be relative or absolute?

It depends on the catalogue type. A SELF_CONTAINED catalogue uses relative hrefs so the whole tree can be copied or moved between hosts without rewriting. An ABSOLUTE_PUBLISHED catalogue uses absolute hrefs pinned to a base URL, which is what most public archives serve. Mixing the two breaks link resolution. Always run normalize_hrefs before save so pystac rewrites every link consistently, and make sure asset hrefs point at the final storage location, not a local build path.

Why does pystac raise an error about a missing datetime?

Every STAC Item must carry a properties.datetime, or an explicit null datetime paired with both start_datetime and end_datetime. If your source raster has no acquisition time and you pass datetime=None without the range fields, validation fails because a temporal reference is mandatory. Supply a representative instant or the range from your source metadata; never silently substitute datetime.now(), which stamps every scene with its publish time rather than its acquisition time.

How do I validate a STAC catalog against the JSON Schemas?

Install pystac with the validation extra (pip install pystac[validation]) and call item.validate(), or run the standalone stac-validator ./catalog.json --recursive CLI. Both fetch the core and extension JSON Schemas for the declared stac_version and stac_extensions and report the failing JSON pointer. Cache the schemas locally in CI so a build never fails because the remote schema host is briefly unreachable.

How large can a single STAC Collection grow before I should shard it?

A static Collection whose item links all live in one JSON file becomes slow to parse past roughly a few thousand Items, because a crawler must read the whole file to reach any single scene. Shard by introducing intermediate sub-catalogs keyed by date, tile, or region so no single JSON object holds more than a few hundred links. Beyond a few hundred thousand Items, move to a STAC API backed by a spatial database rather than static files.


Back to Spatial Metadata & Catalog Integration

Related: