How to build a STAC item for your own raster

Problem statement

You have produced a raster โ€” a composite, a model output, a derived index โ€” and you want it findable and readable the way public imagery is: searchable by footprint and date, with assets a client can range-request without downloading the file.

That is what a STAC item is. It is a GeoJSON Feature with a required datetime, a bounding box, a set of assets with media types and roles, and a set of extensions for the domain-specific fields. Building one with pystac is a dozen lines; building one that a client can actually use takes a few more, and the difference is mostly in the assets.

This guide builds an item from a GeoTIFF, validates it, and covers the things a validator will not tell you.

Quick answer

import datetime, rasterio, pystac
from rasterio.warp import transform_bounds

path = "ndvi_2026-04.tif"
with rasterio.open(path) as src:
    w, s, e, n = transform_bounds(src.crs, "EPSG:4326", *src.bounds, densify_pts=21)
    shape, epsg = [src.height, src.width], src.crs.to_epsg()
    transform = list(src.transform)[:6]

item = pystac.Item(
    id="ndvi-east-sussex-2026-04",
    geometry={"type": "Polygon", "coordinates": [[[w, s], [w, n], [e, n], [e, s], [w, s]]]},
    bbox=[w, s, e, n],
    datetime=datetime.datetime(2026, 4, 30, tzinfo=datetime.timezone.utc),
    properties={"title": "Sentinel-2 NDVI median composite, East Sussex",
                "license": "CC-BY-4.0", "gsd": 10},
)
item.add_asset("data", pystac.Asset(
    href=path, media_type=pystac.MediaType.COG, roles=["data"], title="NDVI"))

pystac.validation.validate(item)

That item validates. Whether it is useful depends on the asset media type, the extensions you declare and the bounding box being in the right order โ€” none of which validation checks in full.

Stack showing the parts of a STAC item: id, geometry, bbox, datetime, properties, extensions, assets and links.
Eight parts; the assets block is the one that decides whether a client can use it.

Step-by-step solution

1. Compute the footprint in EPSG:4326

STAC requires geographic coordinates. Reproject the raster's bounds, and densify the edges โ€” densify_pts=21 in transform_bounds โ€” so a projected rectangle becomes the curved quadrilateral it really is rather than a box that misses ground at the corners.

2. Get the datetime right

datetime must be RFC 3339 with an explicit UTC offset. A date alone is rejected:

'2026-03-14' does not match '(\\+00:00|Z)$'

For a composite covering a period, set datetime=None and supply start_datetime and end_datetime instead.

3. Declare the extensions you use

A field like proj:epsg is defined by the projection extension. Putting it in properties without declaring the extension does not fail validation โ€” it is simply not checked, and it may be ignored by clients. Declare the extension and the field is validated:

'EPSG:4326' is not of type 'integer', 'null'

which is exactly the mistake you want caught.

4. Use the right media type on every asset

pystac.MediaType.COG is what tells a client the file supports range requests. An asset typed as a plain GeoTIFF will be downloaded whole. Add a roles list โ€” data, overview, thumbnail, metadata โ€” so a client can pick.

5. Add the assets a human needs

A thumbnail PNG and a link to the metadata record cost nothing and make the item usable in a browser catalogue.

6. Make the hrefs resolvable

Relative hrefs work when the item sits beside its assets in a static catalogue; absolute URLs are needed when it does not. Decide which and be consistent, or half your items will 404.

7. Put it in a collection

A collection carries the licence, the providers, the shared extent and the summaries. Items inherit from it, so a hundred items need not repeat the licence a hundred times.

8. Validate, then check what validation misses

Validation is a schema check. Verify the bbox order, the footprint against the data, the hrefs and the licence yourself.

Checklist of STAC item checks split into those a validator performs and those it does not.
The validator catches shape errors; the errors that hurt are the ones below the line.

Code examples

Example 1 โ€” an item with the projection and raster extensions

import datetime, rasterio, numpy as np, pystac
from pystac.extensions.projection import ProjectionExtension
from pystac.extensions.raster import RasterExtension, RasterBand
from rasterio.warp import transform_bounds

def item_from_raster(path, item_id, dt, title, licence, band_names):
    with rasterio.open(path) as src:
        w, s, e, n = transform_bounds(src.crs, "EPSG:4326", *src.bounds, densify_pts=21)
        stats = []
        for i in range(1, src.count + 1):
            a = src.read(i, masked=True)
            stats.append(RasterBand.create(
                nodata=src.nodatavals[i - 1],
                data_type=src.dtypes[i - 1],
                spatial_resolution=abs(src.transform.a),
                statistics={"minimum": float(a.min()), "maximum": float(a.max()),
                            "mean": float(a.mean()), "stddev": float(a.std())},
            ))
        epsg, shape, transform = src.crs.to_epsg(), [src.height, src.width], list(src.transform)[:6]

    item = pystac.Item(
        id=item_id,
        geometry={"type": "Polygon", "coordinates": [[[w, s], [w, n], [e, n], [e, s], [w, s]]]},
        bbox=[w, s, e, n], datetime=dt,
        properties={"title": title, "license": licence},
    )

    proj = ProjectionExtension.ext(item, add_if_missing=True)
    proj.epsg, proj.shape, proj.transform = epsg, shape, transform

    asset = pystac.Asset(href=path, media_type=pystac.MediaType.COG,
                         roles=["data"], title=title)
    item.add_asset("data", asset)
    RasterExtension.ext(item.assets["data"], add_if_missing=True).bands = stats
    return item

The band statistics are what let a client build a sensible stretch without reading the raster, and they are the single most useful optional block on a derived product.

Example 2 โ€” a composite covering a period

item = pystac.Item(
    id="ndvi-east-sussex-2026-04",
    geometry=geometry, bbox=bbox,
    datetime=None,
    properties={
        "start_datetime": "2026-04-02T00:00:00Z",
        "end_datetime":   "2026-04-27T23:59:59Z",
        "title": "Sentinel-2 NDVI median composite",
        "license": "CC-BY-4.0",
    },
)

datetime=None is legal only when both range properties are present. Omitting all three fails validation with 'datetime' is a required property.

Example 3 โ€” the checks the validator does not do

import rasterio, requests
from rasterio.warp import transform_bounds
from shapely.geometry import shape, box

def audit_item(item, check_hrefs=True):
    d = item.to_dict()
    problems = []

    w, s, e, n = d["bbox"]
    if w > e or s > n:
        problems.append(f"bbox is not west, south, east, north: {d['bbox']}")
    if not (-180 <= w <= 180 and -180 <= e <= 180 and -90 <= s <= 90 and -90 <= n <= 90):
        problems.append("bbox is outside the geographic range โ€” is it projected?")

    if not shape(d["geometry"]).intersects(box(w, s, e, n)):
        problems.append("geometry and bbox do not overlap")

    if not d["properties"].get("license"):
        problems.append("no licence declared")

    for key, asset in d["assets"].items():
        if not asset.get("type"):
            problems.append(f"asset {key} has no media type")
        if check_hrefs and asset["href"].startswith("http"):
            r = requests.head(asset["href"], timeout=20, allow_redirects=True)
            if r.status_code >= 400:
                problems.append(f"asset {key} href returns {r.status_code}")
    return problems

The bbox order check is the one that matters most. A STAC validator accepts a bounding box whose west is east of its east; the item then validates, publishes, and never matches a search.

Explanation

Why the validator accepts a reversed bbox

The JSON Schema requires an array of four or six numbers. Ordering is a semantic constraint that a schema cannot express, so it is not checked. Any search that intersects your item's bbox against a query box will then miss it, silently, for the life of the catalogue.

Why extensions have to be declared

stac_extensions is a list of schema URLs, and the validator fetches and applies each. An undeclared proj:epsg is just an unknown property in properties, which the core schema permits โ€” so a typo in an extension field passes. Declaring the extension turns the field into something that is checked, which is the entire point of having extensions.

Why the media type is the most important asset field

STAC clients decide what they can do from the media type. The COG media type says "you may range-request this and read a window"; image/tiff says "download it". Getting this wrong turns a cloud-native catalogue into a file server, and nothing in validation notices.

Why collections carry the licence

Repeating the licence, providers and extent on every item is duplication that goes stale. A collection holds them once, items inherit, and a licence change is one edit. Items should carry only what varies between them.

Table of STAC asset roles and media types showing that the cloud-optimised media type enables range requests while a plain GeoTIFF type forces a full download.
Two assets can point at the same file and behave completely differently.

Edge cases or notes

  • Densify the bounds. A projected rectangle is not a geographic one.
  • Antimeridian items need two bboxes or a split geometry; STAC defines the convention.
  • gsd is ground sample distance in metres, not pixel size in degrees.
  • Self and parent links matter for static catalogues; pystac writes them when you normalise a catalogue.
  • Item IDs must be unique within a collection and are part of the URL.
  • Do not put large arrays in properties. Statistics belong in the raster extension.
  • Validation fetches schemas over the network. Cache them for CI.
  • A thumbnail asset makes the item browsable. Cheap, and it is what people look at first.

FAQ

How do I create a STAC item in Python?

With pystac: build an Item with an id, a geographic geometry, a bbox, an RFC 3339 datetime and properties, then add assets with media types and roles.

Why is my datetime rejected?

STAC requires RFC 3339 with an explicit UTC offset. A bare date fails with '2026-03-14' does not match '(\+00:00|Z)$'.

Do I need to declare extensions?

Yes, if you want the extension's fields validated. An undeclared proj:epsg is an unchecked free-form property, so a wrong type passes silently.

What media type should a Cloud Optimised GeoTIFF asset have?

The COG media type, image/tiff; application=geotiff; profile=cloud-optimized. It is what tells a client it may range-request rather than download.

How do I represent a composite covering several dates?

Set datetime=None and supply start_datetime and end_datetime in properties. All three missing fails validation.

What does the validator not catch?

Semantic errors: a reversed bounding box, a footprint that does not match the data, a broken asset href and a wrong licence all validate cleanly.