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
- Python 3.10+ in an isolated virtual environment (
python -m venv .venv && source .venv/bin/activate) pystac>=1.10— STAC object model, serialization, and link managementrasterio>=1.3— reading raster bounds, CRS, transform, and band structurefiona>=1.9— reading vector footprints and attribute schemasshapely>=2.0— geometry construction, footprint simplification, and GeoJSON mappingpyproj>=3.6— reprojecting native bounding boxes to EPSG:4326stac-validator>=3.3(orjsonschema>=4.20) — schema conformance checking- Environment variable
STAC_ROOT_HREFset to the canonical base URL of the published catalog (e.g.https://data.example.gov/stac) - 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"
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 ageometry, abbox, and apropertiesobject 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 — anextentobject 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, andlinksonly; 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 withrel="derived_from"from a derivative Item to the source Item’sselfhref. 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
idand bump apropertiesversion 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 theprojfields 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 |
Static Catalogues Versus an API
STAC is two things that share a vocabulary: a set of JSON documents on storage, and an HTTP API specification for searching them. Which one a team needs is usually settled by a single question, and asking it early avoids building the wrong one.
A static catalogue is a tree of files. It is generated by a pipeline, written to object storage, and read by anything that can fetch JSON. It has no runtime, no database and no operational cost beyond storage. It cannot answer “which Items intersect this polygon between these dates” without the client walking the whole tree.
An API is an index with a query surface. It answers spatial and temporal queries directly, supports paging, and is what a map client expects to talk to. It requires a service, a datastore, and a synchronisation path from the pipeline into the index — which is where the operational cost lives.
The deciding question is who queries the catalogue. If the consumers are pipelines that already know which Collection and which time window they want, the static tree is sufficient and dramatically cheaper. If the consumers are people with a map and a date picker, they need search, and no amount of client-side cleverness substitutes for it at scale.
The path between the two is worth designing for even when only the static form is being built. If the generator writes a well-formed static catalogue with correct extents and resolvable links, standing an index in front of it later is an ingestion job rather than a regeneration. If it writes a tree whose Collection extents are stale and whose links are absolute to a bucket that has since moved, indexing it means fixing it first.
Related
- Automated Metadata Generation & Schema Mapping — the parent topic framing where STAC generation fits in the broader pipeline
- Generating STAC Items from COG Rasters — a focused, self-contained walkthrough for the single-asset case
- DCAT-AP Spatial Profile Mapping — publishing the same assets to European open data portals as RDF
- ISO 19115 Metadata Template Generation — building the ISO records that complement STAC for formal catalog compliance
- Metadata Schema Validation & Linting — the validation patterns that gate STAC records before publication