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 |
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