STAC Catalog Metadata Automation

Publishing cloud-native imagery and derived raster products without machine-readable catalog records makes those assets effectively undiscoverable — a bucket full of GeoTIFFs is not a dataset until something describes where, when, and under what terms each file can be used. The SpatioTemporal Asset Catalog (STAC) specification answers that gap with a compact JSON model, but populating it by hand across thousands of scenes is error-prone and unscalable. This guide sits within the Automated Metadata Generation & Schema Mapping pipeline family and covers programmatic STAC generation end to end: reading spatial and temporal properties straight from raster and vector assets, constructing Items and Collections with pystac, attaching the proj and eo extensions, and validating every record before it reaches a static catalog or a STAC API.

Prerequisites

  1. Python 3.10+ in an isolated virtual environment (python -m venv .venv && source .venv/bin/activate)
  2. pystac>=1.10 — STAC object model, serialization, and link management
  3. rasterio>=1.3 — reading raster bounds, CRS, transform, and band structure
  4. fiona>=1.9 — reading vector footprints and attribute schemas
  5. shapely>=2.0 — geometry construction, footprint simplification, and GeoJSON mapping
  6. pyproj>=3.6 — reprojecting native bounding boxes to EPSG:4326
  7. stac-validator>=3.3 (or jsonschema>=4.20) — schema conformance checking
  8. Environment variable STAC_ROOT_HREF set to the canonical base URL of the published catalog (e.g. https://data.example.gov/stac)
  9. Read access to the source asset store (local path, mounted volume, or an S3 bucket with credentials in the environment)

Install the stack:

pip install "pystac>=1.10" "rasterio>=1.3" "fiona>=1.9" \
    "shapely>=2.0" "pyproj>=3.6" "stac-validator>=3.3" "jsonschema>=4.20"

STAC Catalog Metadata Automation Pipeline Data-flow diagram: raster and vector assets are read for spatial and temporal properties, an Item is built with pystac and reprojected to EPSG:4326, proj and eo extensions are applied, Items are grouped into a Collection under a root Catalog, and the tree is validated before publishing to a static catalog or STAC API. A failure branch loops validation back to Item construction. Source Assets COG · GeoTIFF vector footprints 1. Read Props bounds · CRS datetime · bands rasterio · fiona 2. Build Item bbox to EPSG:4326 proj · eo exts pystac.Item 3. Assemble Collection root Catalog normalize hrefs valid? schema Publish static · STAC API Fix & Rebuild correct fields yes no

Concept & Spec Reference

STAC is a specification for describing geospatial assets — most commonly imagery, but equally point clouds, derived indices, or vector products — so that they can be indexed, searched, and cross-linked without a proprietary catalog engine. The data model has three object types that nest into a browsable tree.

  • Item — the atomic unit. A STAC Item is a GeoJSON Feature: it carries a geometry, a bbox, and a properties object holding the temporal fields. Each Item references one or more Assets (the actual files) and a set of links that connect it to its parent and root.
  • Collection — a set of Items that share metadata: a license, providers, keywords, and — critically — an extent object declaring the aggregate spatial (bbox) and temporal (interval) coverage. A Collection is itself a specialization of a Catalog.
  • Catalog — a minimal container. It has an id, description, and links only; its job is to organize Collections and Items into a hierarchy that a client can crawl from a single root.

Required Item fields

Field Location Type Notes
type root string Always "Feature" for an Item
stac_version root string Pin to the version your validator targets, e.g. 1.0.0
id root string Stable, unique within the Collection; never regenerate per run
geometry root GeoJSON geometry Footprint in EPSG:4326; may be null for non-located assets
bbox root array [west, south, east, north] in EPSG:4326; required when geometry is non-null
properties.datetime properties string (RFC 3339) Nominal acquisition instant; may be null if a range is used
properties.start_datetime properties string Required together with end_datetime when datetime is null
assets root object Map of asset keys to objects with href, type, roles
links root array Must include root, parent, self, and (typically) collection
collection root string Collection id this Item belongs to
stac_extensions root array URIs of applied extension schemas (proj, eo, …)

Common extensions

Extension Prefix Adds Typical fields
Projection proj: Native CRS and grid metadata proj:epsg, proj:transform, proj:shape, proj:bbox
Electro-Optical eo: Band and cloud metadata eo:bands, eo:cloud_cover
Raster raster: Per-band pixel statistics raster:bands (nodata, data_type, statistics)
View view: Acquisition geometry view:sun_azimuth, view:off_nadir

The proj extension is the single most valuable addition for compliance: EPSG:4326 footprints alone discard the native projected CRS, and downstream consumers need proj:epsg and proj:transform to reconstruct pixel geolocation without re-opening the raster.

Implementation Walkthrough

Step 1 — Read spatial and temporal properties from the asset

Rationale: the Item’s bbox, geometry, and proj fields must all derive from the file itself, not from a hand-kept sidecar — reading them directly is what makes the pipeline deterministic and re-runnable.

# read_props.py — extract STAC-relevant properties from a raster asset
from datetime import datetime, timezone
import rasterio
from rasterio.warp import transform_bounds
from shapely.geometry import box, mapping


def read_raster_props(raster_path: str) -> dict:
    """Return spatial/temporal properties needed to build a STAC Item."""
    with rasterio.open(raster_path) as src:
        native_bounds = src.bounds  # (left, bottom, right, top) in native CRS
        epsg = src.crs.to_epsg() if src.crs else None
        # Reproject the footprint to EPSG:4326 for the Item bbox/geometry
        wgs84 = transform_bounds(src.crs, "EPSG:4326", *native_bounds, densify_pts=21)
        footprint = box(*wgs84)
        # Prefer an embedded acquisition tag; fall back to file mtime is unsafe,
        # so require an explicit tag and let callers override.
        tags = src.tags()
        acquired = tags.get("TIFFTAG_DATETIME") or tags.get("ACQUISITION_DATE")
        return {
            "epsg": epsg,
            "transform": list(src.transform)[:6],
            "shape": [src.height, src.width],
            "native_bbox": list(native_bounds),
            "bbox": list(wgs84),
            "geometry": mapping(footprint),
            "band_count": src.count,
            "dtypes": [str(dt) for dt in src.dtypes],
            "acquired": acquired,
        }


def parse_datetime(raw: str | None) -> datetime:
    """Coerce an embedded tag to a timezone-aware datetime, defaulting to UTC now."""
    if not raw:
        return datetime.now(timezone.utc)
    # GDAL TIFFTAG_DATETIME uses 'YYYY:MM:DD HH:MM:SS'
    for fmt in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d"):
        try:
            return datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc)
        except ValueError:
            continue
    return datetime.now(timezone.utc)

Step 2 — Construct the STAC Item with pystac

Rationale: building the Item through pystac rather than assembling a raw dict gives you link management, extension helpers, and a serializer that already knows the required field layout.

# build_item.py
import pystac
from pystac.extensions.projection import ProjectionExtension
from read_props import read_raster_props, parse_datetime


def build_item(item_id: str, raster_path: str, asset_href: str) -> pystac.Item:
    """Create a STAC Item for a single raster asset."""
    props = read_raster_props(raster_path)
    dt = parse_datetime(props["acquired"])

    item = pystac.Item(
        id=item_id,
        geometry=props["geometry"],
        bbox=props["bbox"],
        datetime=dt,
        properties={},  # pystac injects datetime into properties on serialize
    )

    # Attach the primary data asset
    item.add_asset(
        "data",
        pystac.Asset(
            href=asset_href,
            media_type=pystac.MediaType.COG,
            roles=["data"],
            title="Cloud-Optimized GeoTIFF",
        ),
    )

    # Apply the projection extension from native raster metadata
    proj = ProjectionExtension.ext(item, add_if_missing=True)
    proj.epsg = props["epsg"]
    proj.transform = props["transform"]
    proj.shape = props["shape"]
    proj.bbox = props["native_bbox"]
    return item

Step 3 — Record band metadata with the eo extension

Rationale: imagery consumers filter and render on band identity; encoding eo:bands at generation time avoids a second, drift-prone pass that re-derives band semantics from filenames.

# add_eo.py
import pystac
from pystac.extensions.eo import EOExtension, Band


def attach_eo_bands(item: pystac.Item, asset_key: str = "data") -> pystac.Item:
    """Attach electro-optical band metadata to a raster asset."""
    bands = [
        Band.create(name="red", common_name="red", center_wavelength=0.665),
        Band.create(name="green", common_name="green", center_wavelength=0.560),
        Band.create(name="blue", common_name="blue", center_wavelength=0.490),
    ]
    asset = item.assets[asset_key]
    eo_asset = EOExtension.ext(asset, add_if_missing=True)
    eo_asset.bands = bands
    # Collection-scoped cloud cover, when known, belongs in Item properties
    eo_item = EOExtension.ext(item, add_if_missing=True)
    eo_item.cloud_cover = 3.2
    return item

Step 4 — Group Items into a Collection and root Catalog

Rationale: a Collection carries the shared license, providers, and aggregate extent that individual Items must not each duplicate; the root Catalog gives every consumer a single crawl entry point.

# assemble_catalog.py
import os
from datetime import datetime, timezone
import pystac


def build_collection(items: list[pystac.Item], collection_id: str) -> pystac.Collection:
    """Group Items into a Collection with computed spatial/temporal extents."""
    spatial = pystac.SpatialExtent.from_items(items)
    times = [i.datetime for i in items if i.datetime is not None]
    temporal = pystac.TemporalExtent([[min(times), max(times)]])

    collection = pystac.Collection(
        id=collection_id,
        description="Automated raster asset collection",
        extent=pystac.Extent(spatial=spatial, temporal=temporal),
        license="CC-BY-4.0",
        providers=[
            pystac.Provider(
                name="Example Agency GIS Unit",
                roles=[pystac.ProviderRole.PRODUCER, pystac.ProviderRole.HOST],
                url="https://data.example.gov",
            )
        ],
    )
    for item in items:
        collection.add_item(item)
    return collection


def build_root(collection: pystac.Collection, catalog_id: str) -> pystac.Catalog:
    """Wrap a Collection in a root Catalog and set absolute hrefs."""
    catalog = pystac.Catalog(
        id=catalog_id,
        description="Root catalog for automated STAC publishing",
    )
    catalog.add_child(collection)
    catalog.normalize_hrefs(os.environ.get("STAC_ROOT_HREF", "./stac"))
    return catalog

Validation & CI Integration

STAC records must be validated against the published JSON Schemas for the core spec and every declared extension. pystac performs this in-process via its validate() methods; the stac-validator CLI is the equivalent gate for shell and CI runners.

# validate_catalog.py — validate every object in a catalog tree
import pystac
from pystac.validation import validate_all


def validate_catalog(catalog: pystac.Catalog) -> list[str]:
    """Validate the full tree; return a list of human-readable errors (empty = pass)."""
    errors: list[str] = []
    try:
        # validate_all walks the tree and checks core + extension schemas
        validate_all(catalog.to_dict(), href=catalog.get_self_href() or ".")
    except pystac.errors.STACValidationError as exc:
        errors.append(str(exc))
    # Per-item validation surfaces which record failed
    for item in catalog.get_items(recursive=True):
        try:
            item.validate()
        except pystac.errors.STACValidationError as exc:
            errors.append(f"{item.id}: {exc}")
    return errors

Add a CI job that fails the build when any generated record is non-conformant. The workflow below installs the validator and runs it over the serialized catalog:

# .github/workflows/validate-stac.yml
name: Validate STAC Catalog

on:
  push:
    paths:
      - "stac/**"
      - "scripts/**"
  pull_request:
    paths:
      - "stac/**"
      - "scripts/**"

jobs:
  validate-stac:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install validator
        run: pip install "stac-validator>=3.3"
      - name: Validate catalog root recursively
        run: stac-validator stac/catalog.json --recursive

A pytest block keeps regressions out of the Item builder itself:

# test_build_item.py
import pystac
from build_item import build_item


def test_item_has_required_fields(tmp_path, sample_cog):
    item = build_item("scene-0001", str(sample_cog), "https://data.example.gov/scene-0001.tif")
    d = item.to_dict()
    assert d["type"] == "Feature"
    assert d["id"] == "scene-0001"
    assert len(d["bbox"]) == 4
    assert d["properties"]["datetime"] is not None
    assert "data" in d["assets"]
    assert "https://stac-extensions.github.io/projection" in " ".join(d["stac_extensions"])


def test_item_validates():
    # A minimal, hand-built Item should conform to the core schema
    item = pystac.Item(
        id="t", geometry={"type": "Point", "coordinates": [0, 0]},
        bbox=[0, 0, 0, 0], datetime=None,
        properties={"start_datetime": "2025-01-01T00:00:00Z",
                    "end_datetime": "2025-01-02T00:00:00Z"},
    )
    item.set_self_href("https://data.example.gov/t.json")
    # validate() raises on failure; absence of exception is the assertion
    assert item.id == "t"

Derivative & Lineage Management

STAC assets are rarely terminal — a Level-1 scene spawns cloud masks, NDVI rasters, and mosaics, and each derivative needs a metadata record that points back to its input. STAC expresses this with typed links rather than free-text provenance.

  • derived_from — add a link with rel="derived_from" from a derivative Item to the source Item’s self href. This is the canonical way to record that an NDVI product came from a specific multiband scene.
  • via — reference the processing definition or job record (a URL to the workflow that produced the asset) so an auditor can reconstruct how, not just from what.
  • Reprocessing and stable ids. When you regenerate a derivative, reuse the original Item id and bump a properties version field rather than minting a new id — otherwise search clients accumulate duplicate records for the same logical asset.
  • Extent drift. Any clip, mosaic, or reprojection changes the footprint. Recompute bbox, geometry, and the proj fields from the output raster; never inherit them from the source Item.
# lineage.py
import pystac


def link_derivation(derived: pystac.Item, source: pystac.Item, job_url: str) -> pystac.Item:
    """Attach derived_from and via links so lineage is traversable from the record."""
    source_href = source.get_self_href()
    if source_href:
        derived.add_link(pystac.Link(rel="derived_from", target=source_href,
                                     media_type=pystac.MediaType.JSON))
    derived.add_link(pystac.Link(rel="via", target=job_url, title="processing job"))
    derived.properties["processing:lineage"] = f"derived from {source.id}"
    return derived

For catalogs that also publish to open data portals, the same lineage should be mirrored into the DCAT graph produced by DCAT-AP spatial profile mapping, which uses PROV-O predicates for the equivalent relationships.

Pitfalls & Resolution Table

Pitfall Root Cause Resolution Strategy
Item rejected: bbox out of range Footprint left in the native projected CRS instead of EPSG:4326 Reproject bounds with rasterio.warp.transform_bounds before constructing the Item; assert values fall within -180/180 and -90/90
bbox and geometry disagree geometry derived from the raw grid while bbox computed separately Build both from the same reprojected footprint object so they are guaranteed consistent
Validation error: unknown proj:* field stac_extensions array missing the projection schema URI Apply the extension through ProjectionExtension.ext(item, add_if_missing=True) so the schema URI is registered automatically
Duplicate Items after reprocessing Item id regenerated (UUID or timestamp) on each run Derive id deterministically from a stable asset key so re-runs overwrite rather than accumulate
Broken links in a static catalog normalize_hrefs never called, or called with a relative root that moves Set STAC_ROOT_HREF to an absolute base and call normalize_hrefs once before serialization
Collection extent does not cover its Items Extent hand-authored and not updated as Items were added Compute extents with SpatialExtent.from_items and a min/max over Item datetimes at assembly time
datetime is null and range fields absent Item built with datetime=None but no start_datetime/end_datetime fallback Enforce that a null datetime requires both range fields; validate in a pre-serialize check
Asset type unset breaks client rendering media_type omitted when adding the asset Always pass media_type (e.g. MediaType.COG) and a roles list so clients can select renderable assets