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.
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.
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.
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.
gsdis 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.
Internal links
- A STAC item is rejected by the validator โ the errors and what they mean
- STAC catalogues explained โ items, collections and catalogues
- Metadata standards compared: ISO 19115, STAC and Frictionless โ when STAC is the right shape
- How to write a Cloud Optimised GeoTIFF in Python โ making the asset worth a COG media type
- How to download satellite imagery from a STAC catalogue in Python โ the consumer side
- How to read a COG from a URL in Python โ what the media type promises
- How to embed provenance tags in a GeoTIFF โ metadata inside the asset
- Metadata extents and dates do not match the data โ the checks after validation
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.