Creating STAC Items for Cloud-Optimized GeoTIFFs

TL;DR: Open the Cloud-Optimized GeoTIFF with rasterio to read its native bounds, crs, and affine transform; reproject the bounds to EPSG:4326 with transform_bounds to populate the Item bbox and a mapping(box(...)) geometry; then build a pystac.Item, attach a pystac.Asset with media_type=pystac.MediaType.COG and roles=["data"], apply the projection extension (proj:epsg, proj:shape, proj:transform), set a self href, and call item.validate().

A Cloud-Optimized GeoTIFF (COG) is a regular GeoTIFF whose internal tiling and overviews are laid out so that HTTP range requests can read a window without downloading the whole file. STAC — the SpatioTemporal Asset Catalog specification — is the metadata layer that makes such a file discoverable: a STAC Item is a GeoJSON Feature that records where and when the data was captured and where the bytes live. This guide builds one Item, correctly, from a single COG.

The Core Challenge

The friction is a mismatch of coordinate reference systems. A STAC Item is required to express its bbox and geometry in EPSG:4326 (WGS84 longitude/latitude) so that a catalogue can run a spatial query against Items from wildly different projections without reprojecting anything at search time. Your COG, however, is almost always stored in a projected CRS — a UTM zone, a national grid, or Web Mercator — because that is what keeps pixels square and areas meaningful for analysis.

So you cannot simply copy the raster’s bounds into the Item. You must read the native extent, reproject it to geographic coordinates for the top-level bbox/geometry, and then preserve the original projection and pixel grid separately through the STAC projection extension. That extension carries proj:epsg, proj:shape, and proj:transform, which together let a client reconstruct the exact raster grid — origin, pixel size, and orientation — without opening the file. Getting both halves right is what separates a searchable, analysis-ready Item from one that validates but silently misplaces the data. The same reprojection discipline underpins the SRS and Coordinate Reference System Handling guide.

COG to STAC Item pipeline A left-to-right pipeline: rasterio reads native bounds, CRS and transform from a COG; the bounds are reprojected to EPSG:4326 for the Item bbox and geometry; a pystac Item is built with a COG asset and the projection extension carries the native grid; the Item is validated. rasterio.open reads bounds, crs, transform, width, height COG (native CRS) transform_bounds native bounds to EPSG:4326 pystac.Item bbox + geometry (WGS84) + datetime + COG asset projection ext proj:epsg / shape / transform then validate() Item-level geometry stays in EPSG:4326; the native grid is preserved by the projection extension

Production-Ready Code

The script below builds a single, valid STAC Item from one COG. It depends only on pystac, rasterio, and shapely (all pip-installable; pyproj and shapely arrive with rasterio and pystac respectively, but pin shapely explicitly to be safe).

# pip install "pystac[validation]>=1.10" "rasterio>=1.3" "shapely>=2.0"
from datetime import datetime, timezone

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


def build_cog_item(cog_href: str, item_id: str, captured: datetime) -> pystac.Item:
    """Create a validated STAC Item describing a single Cloud-Optimized GeoTIFF."""
    # 1. Read the raster's native geometry. rasterio opens local paths, http(s)
    #    URLs, and s3:// keys, so cog_href can point at the published asset.
    with rasterio.open(cog_href) as src:
        native_crs = src.crs                 # e.g. EPSG:32633 (a UTM zone)
        native_bounds = src.bounds           # (left, bottom, right, top), native CRS
        affine = src.transform               # 6-coefficient affine grid mapping
        height, width = src.height, src.width

    # 2. Reproject the native bounds to EPSG:4326 for the Item bbox/geometry.
    #    transform_bounds densifies the edges so a rotated/curved footprint
    #    is not clipped by naive corner-only reprojection.
    west, south, east, north = transform_bounds(
        native_crs, "EPSG:4326", *native_bounds, densify_pts=21
    )
    bbox = [west, south, east, north]
    geometry = mapping(box(west, south, east, north))  # GeoJSON Polygon

    # 3. Build the Item. datetime MUST be timezone-aware (STAC stores UTC).
    item = pystac.Item(
        id=item_id,
        geometry=geometry,
        bbox=bbox,
        datetime=captured.astimezone(timezone.utc),
        properties={},
    )

    # 4. Attach the COG as a data asset with the correct media type.
    item.add_asset(
        "data",
        pystac.Asset(
            href=cog_href,
            media_type=pystac.MediaType.COG,
            roles=["data"],
            title="Cloud-Optimized GeoTIFF",
        ),
    )

    # 5. Apply the projection extension so clients recover the native grid.
    proj = ProjectionExtension.ext(item, add_if_missing=True)
    proj.epsg = native_crs.to_epsg()          # proj:epsg
    proj.shape = [height, width]              # proj:shape  -> [rows, cols]
    proj.transform = list(affine)[:6]         # proj:transform -> 6 coefficients

    return item


if __name__ == "__main__":
    item = build_cog_item(
        cog_href="https://example.org/archive/scene-2026-07-10.tif",
        item_id="scene-2026-07-10",
        captured=datetime(2026, 7, 10, 10, 30, tzinfo=timezone.utc),
    )

    # An absolute self href resolves relative asset paths and anchors links.
    item.set_self_href(
        "https://example.org/catalog/scene-2026-07-10/scene-2026-07-10.json"
    )

    item.validate()          # raises STACValidationError on any schema violation
    print(item.id, "->", item.bbox)

Step-by-Step Walkthrough

Reading native geometry with rasterio.open. The with rasterio.open(cog_href) block reads four things and nothing more: src.crs (the native coordinate reference system), src.bounds (the extent as left, bottom, right, top in that CRS), src.transform (the affine object mapping pixel (row, col) to map coordinates), and src.height/src.width (the pixel grid dimensions). Because rasterio resolves http(s) and s3:// hrefs through GDAL’s virtual file system, the same function that reads a local scratch file also reads the published cloud object, so cog_href can be the final asset URL.

Reprojecting bounds with transform_bounds. rasterio.warp.transform_bounds takes the source CRS, the target CRS ("EPSG:4326"), and the four native bound values, and returns them in geographic degrees. The densify_pts=21 argument inserts intermediate points along each edge before reprojecting. This matters when the source projection curves straight lines — a corner-only transform of a wide UTM tile can under-report the true north/south extent by kilometres, producing a bbox that fails to enclose the footprint. The returned west, south, east, north feed both the flat four-element bbox and the polygon.

Building the geometry with mapping(box(...)). shapely.geometry.box(west, south, east, north) constructs a rectangular polygon, and shapely.geometry.mapping serialises it to a GeoJSON-shaped dict. That dict is exactly what pystac.Item expects for its geometry argument. For a rectangular COG footprint this axis-aligned box is correct; for imagery with a rotated or irregular valid-data mask you would substitute the actual data footprint polygon here.

Constructing the pystac.Item. The datetime argument must be timezone-aware — STAC records instants in UTC, and passing a naive datetime risks an off-by-hours error or a validation failure. captured.astimezone(timezone.utc) normalises whatever zone the caller supplied. The properties={} starts empty; the projection extension will populate it next.

Adding the COG asset. item.add_asset("data", ...) keys the asset as data and gives it media_type=pystac.MediaType.COG, the registered media type image/tiff; application=geotiff; profile=cloud-optimized. The roles=["data"] list tells clients this asset is the primary payload rather than a thumbnail or metadata sidecar. Using the enum instead of a hand-typed string avoids a common typo that stops COG-aware clients from recognising the asset.

Applying the projection extension. ProjectionExtension.ext(item, add_if_missing=True) registers the extension’s schema URI in item.stac_extensions and returns a typed accessor. Setting proj.epsg from native_crs.to_epsg() writes proj:epsg; proj.shape = [height, width] writes proj:shape in row-major order (rows first, then columns); and proj.transform = list(affine)[:6] writes the six affine coefficients as proj:transform. A rasterio Affine iterates to nine values, so the [:6] slice keeps the a, b, c, d, e, f coefficients the extension expects.

Self href and validation. item.set_self_href(...) gives the Item a canonical location; this is what any relative asset href would resolve against, and it is required before you can rewrite hrefs to be relative. item.validate() then downloads the core STAC schema and the projection extension schema and checks the Item against both, raising STACValidationError on any violation.

Verification

Run the script and confirm the Item reports a plausible geographic bbox and that validation returns without raising:

python build_cog_item.py

Expected output:

scene-2026-07-10 -> [12.0, 41.8, 12.6, 42.1]

For a deeper check, inspect the serialised Item and confirm the projection fields survived the round-trip:

import json
print(json.dumps(item.to_dict()["properties"], indent=2))
{
  "proj:epsg": 32633,
  "proj:shape": [10980, 10980],
  "proj:transform": [10.0, 0.0, 300000.0, 0.0, -10.0, 4700040.0],
  "datetime": "2026-07-10T10:30:00Z"
}

A proj:epsg matching your source raster, a proj:shape equal to [height, width], and a negative fifth transform coefficient (the north-up pixel-height sign) together confirm the native grid was captured correctly. If you also run the record through the Schema Validation for Spatial Records tooling, the same JSON schema mechanism item.validate() uses will flag any structural drift before publication.

Gotchas & Edge Cases

Why must the STAC Item bbox and geometry be in EPSG:4326 when my COG is in a projected CRS?

The STAC specification requires the Item-level bbox and geometry to be expressed in WGS84 longitude/latitude (EPSG:4326) so that any catalogue can perform a spatial search without knowing the source projection. If you write native UTM eastings and northings into the bbox, every spatial query in the catalogue will misplace the Item — often into the ocean off West Africa where projected metres masquerade as degrees near (0, 0). The native CRS and grid are not lost: they are preserved separately through the projection extension fields proj:epsg, proj:shape, and proj:transform.

Should STAC asset hrefs be absolute or relative?

Both are valid, and the choice is about portability. Absolute hrefs — an https:// or s3:// URL — are self-describing and survive the Item being copied or moved, which suits a published cloud archive whose data lives at a stable URL. Relative hrefs keep an Item portable alongside its data on disk, but they are only resolvable once the Item has a self href to resolve against. Call item.set_self_href(...) before saving, then item.make_asset_hrefs_relative() if you want portable relative paths, or item.make_asset_hrefs_absolute() to bake in fully-qualified URLs.

Why does item.validate() raise an error about the projection extension schema?

item.validate() downloads the JSON schemas referenced in the Item’s stac_extensions list, so it needs network access plus the jsonschema package (installed by the pystac[validation] extra). The most common failure is setting proj:epsg to None — which happens when native_crs.to_epsg() cannot resolve a code for a custom or WKT-only CRS — without providing proj:wkt2 or proj:projjson instead. The projection extension requires exactly one CRS descriptor, so supply a valid EPSG code, or fall back to proj.wkt2 = native_crs.to_wkt() when no EPSG code exists.

What is the difference between proj:shape and the Item geometry?

proj:shape is the pixel dimensions of the raster grid as [height, width] — rows first, then columns — in the native CRS. The Item geometry is a GeoJSON polygon of the data footprint in EPSG:4326. One describes the discrete raster array (how many pixels, in what grid), and the other describes the continuous geographic extent used for search. Swapping the order of proj:shape to [width, height] is a frequent mistake that passes validation but silently misreports non-square rasters to any client that reconstructs the grid.


Back to STAC Catalog Publishing for Raster Archives

Related