How to build a STAC item for your own raster
On this page
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"))
import pystac.validation
print(pystac.validation.validate(item))
print(item.bbox)
['https://schemas.stacspec.org/v1.1.0/item-spec/json-schema/item.json']
[-0.10624580077843575, 50.815945958113396, 0.06643504476266959, 50.924093610421195]
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. transform_bounds with densify_pts=21 samples points along each edge before reprojecting, so the bbox encloses the whole raster rather than just its four corners. It is still a box, though: the raster's real outline in longitude and latitude is a slightly skewed quadrilateral, and the geometry can say so:
from shapely.geometry import box, mapping, shape
from shapely.ops import transform as shp_transform
from pyproj import Transformer
with rasterio.open(path) as src:
to_4326 = Transformer.from_crs(src.crs, "EPSG:4326", always_xy=True).transform
outline = shp_transform(to_4326, box(*src.bounds).segmentize(100)) # a vertex every 100 m
xs, ys = zip(*(to_4326(x, y) for x in (src.bounds.left, src.bounds.right)
for y in (src.bounds.bottom, src.bounds.top)))
corners_only = (min(xs), min(ys), max(xs), max(ys))
print("bbox, corners only:", [round(v, 5) for v in corners_only])
print("bbox, densified: ", [round(v, 5) for v in (w, s, e, n)])
print(f"outline fills {outline.area / box(w, s, e, n).area:.2%} of its bbox")
footprint = mapping(outline.simplify(1e-6))
bbox, corners only: [-0.10625, 50.81595, 0.06644, 50.92409]
bbox, densified: [-0.10625, 50.81595, 0.06644, 50.92409]
outline fills 92.54% of its bbox
For a raster this size the densified and corner-only bboxes agree to the fifth decimal, so densifying changes nothing here. The outline is another matter: it fills only 92.5% of its bbox. Lewes sits at longitude 0, three degrees east of UTM zone 30's central meridian, where the zone's grid north is turned about 2.3ยฐ from true north โ so the raster's rectangle, drawn in longitude and latitude, is a tilted quadrilateral, and 7.5% of the box around it is ground the raster does not cover. A bbox-shaped geometry would claim it. Use the outline as the item's geometry and the box as its bbox.
2. Get the datetime right
datetime must be RFC 3339 with an explicit UTC offset. A date alone is rejected:
import copy
bad = copy.deepcopy(item.to_dict())
bad["properties"]["datetime"] = "2026-03-14"
try:
pystac.validation.validate_dict(bad)
except pystac.errors.STACValidationError as exc:
print(str(exc).splitlines()[1])
'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:code 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:
from pystac.extensions.projection import ProjectionExtension
def first_error(d):
try:
pystac.validation.validate_dict(d)
return "valid"
except pystac.errors.STACValidationError as exc:
return str(exc).splitlines()[1]
undeclared = copy.deepcopy(item.to_dict())
undeclared["properties"]["proj:code"] = 32630 # should be the string "EPSG:32630"
print("undeclared, wrong type:", first_error(undeclared))
declared = item.clone()
ProjectionExtension.ext(declared, add_if_missing=True).epsg = 32630
print("pystac writes:", {k: v for k, v in declared.properties.items() if k.startswith("proj:")})
d = declared.to_dict()
d["properties"]["proj:code"] = 32630
print("declared, wrong type: ", first_error(d))
d["properties"]["proj:epsg"] = 32630
del d["properties"]["proj:code"]
print("declared, old field: ", first_error(d))
undeclared, wrong type: valid
pystac writes: {'proj:code': 'EPSG:32630'}
declared, wrong type: 32630 is not of type 'string', 'null'
declared, old field: 'proj:epsg' does not match any of the regexes: '^(?!proj:)'
The last line is the one that catches people copying older examples. Version 2.0 of the projection extension, which pystac 1.15 declares, replaced the integer proj:epsg with a string proj:code; pystac's .epsg setter writes the new field for you, but a hand-written proj:epsg is now rejected outright.
4. Use the right media type on every asset
pystac.MediaType.COG โ image/tiff; application=geotiff; profile=cloud-optimized โ is how a client knows, before opening the file, that it is laid out for range requests. The label does not make the file cloud-optimised, and GDAL will attempt range reads on any TIFF whatever the label says; what the media type changes is what a catalogue client decides to offer, and a mislabelled asset is taken at its word. 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 little and make the item usable in a browser catalogue:
import matplotlib.pyplot as plt
with rasterio.open(path) as src:
preview = src.read(1, out_shape=(src.height // 4, src.width // 4)) # served from the overviews
plt.imsave("ndvi_2026-04_thumb.png", preview, cmap="RdYlGn", vmin=-0.2, vmax=0.9)
item.add_asset("thumbnail", pystac.Asset(href="ndvi_2026-04_thumb.png",
media_type=pystac.MediaType.PNG, roles=["thumbnail"]))
print(preview.shape, f"{Path('ndvi_2026-04_thumb.png').stat().st_size / 1024:.0f} KB")
(289, 292) 187 KB
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
The data used on this page
A real derived raster: the median NDVI of April 2026 over Lewes and the South Downs, from the seven Sentinel-2 L2A scenes of MGRS tile 30UYB with under 10% cloud in Element 84's Earth Search catalogue, with clouds, shadows and water masked by each scene's scene classification band. It is written as a Cloud Optimised GeoTIFF, the kind of file this page gives a STAC item.
One of the seven items reports earthsearch:boa_offset_applied: false while its red band's median over this window is 502, against 558 for the other pass that day โ the offset has plainly been applied already, and subtracting another 1,000 would have pushed most of the scene below zero. The composite applies no offset to any of them.
from pathlib import Path
import numpy as np
import pystac_client
import rasterio
from rasterio.enums import Resampling
from rasterio.warp import transform_bounds
from rasterio.windows import from_bounds
BBOX_4326 = (-0.10, 50.82, 0.06, 50.92)
OUT = Path("ndvi_2026-04.tif")
items = list(pystac_client.Client.open("https://earth-search.aws.element84.com/v1").search(
collections=["sentinel-2-l2a"], bbox=BBOX_4326, datetime="2026-04-01/2026-04-30",
query={"eo:cloud_cover": {"lt": 10}, "grid:code": {"eq": "MGRS-30UYB"}}).items())
if not OUT.exists():
layers = []
for it in items:
with rasterio.open(it.assets["red"].href) as r:
win = from_bounds(*transform_bounds("EPSG:4326", r.crs, *BBOX_4326), r.transform)
win = win.round_offsets().round_lengths()
red = r.read(1, window=win).astype("float32")
profile = dict(crs=r.crs, transform=r.window_transform(win))
with rasterio.open(it.assets["nir"].href) as r:
nir = r.read(1, window=win).astype("float32")
with rasterio.open(it.assets["scl"].href) as r: # 20 m, read onto the 10 m grid
scl = r.read(1, window=from_bounds(*transform_bounds("EPSG:4326", r.crs, *BBOX_4326), r.transform),
out_shape=red.shape, resampling=Resampling.nearest)
clear = np.isin(scl, [4, 5, 7]) & (red + nir > 0) # vegetation, bare, unclassified
with np.errstate(invalid="ignore", divide="ignore"):
layers.append(np.where(clear, (nir - red) / (nir + red), np.nan))
with np.errstate(all="ignore"):
ndvi = np.nanmedian(np.stack(layers), axis=0).astype("float32")
with rasterio.open(OUT, "w", driver="COG", width=ndvi.shape[1], height=ndvi.shape[0], count=1,
dtype="float32", nodata=np.nan, compress="deflate", **profile) as dst:
dst.write(ndvi, 1)
for it in items: # the two passes of 27 April
if it.datetime.date().isoformat() == "2026-04-27":
with rasterio.open(it.assets["red"].href) as r:
win = from_bounds(*transform_bounds("EPSG:4326", r.crs, *BBOX_4326), r.transform)
print(it.id, "boa_offset_applied:", it.properties["earthsearch:boa_offset_applied"],
"| red median", np.median(r.read(1, window=win.round_offsets().round_lengths())))
with rasterio.open(OUT) as src:
print(len(items), "scenes |", src.width, "x", src.height, src.crs, "| nodata", src.nodata,
f"| {np.isnan(src.read(1)).mean():.1%} empty | {OUT.stat().st_size / 1e6:.1f} MB")
S2A_30UYB_20260427_1_L2A boa_offset_applied: False | red median 502.0
S2B_30UYB_20260427_0_L2A boa_offset_applied: True | red median 558.0
7 scenes | 1170 x 1157 EPSG:32630 | nodata nan | 0.1% empty | 6.1 MB
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, Statistics
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=Statistics.create(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
full = item_from_raster(str(OUT), "ndvi-lewes-2026-04",
datetime.datetime(2026, 4, 30, tzinfo=datetime.timezone.utc),
"Sentinel-2 NDVI median, April 2026, Lewes", "CC-BY-4.0", ["ndvi"])
band = full.assets["data"].extra_fields["raster:bands"][0]
print({k: v for k, v in full.properties.items() if k.startswith("proj:")})
print(band)
import json
try:
json.dumps(full.to_dict(), allow_nan=False)
except ValueError as exc:
print("strict JSON:", exc)
band["nodata"] = "nan" # the raster extension's spelling for NaN
json.dumps(full.to_dict(), allow_nan=False)
try:
print(pystac.validation.validate(full))
except pystac.errors.STACValidationError as exc:
print("invalid:", str(exc).splitlines()[1])
{'proj:code': 'EPSG:32630', 'proj:shape': [1157, 1170], 'proj:transform': [10.0, 0.0, 703820.0, 0.0, -10.0, 5645390.0]}
{'nodata': nan, 'data_type': 'float32', 'spatial_resolution': 10.0, 'statistics': {'minimum': -0.3988304138183594, 'maximum': 0.9533129930496216, 'mean': 0.6952024878932387, 'stddev': 0.20285502622808205}}
strict JSON: Out of range float values are not JSON compliant: nan
['https://schemas.stacspec.org/v1.1.0/item-spec/json-schema/item.json', 'https://stac-extensions.github.io/projection/v2.0.0/schema.json', 'https://stac-extensions.github.io/raster/v1.1.0/schema.json']
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.
The NaN is worth a second look. An NDVI raster's natural nodata is NaN, rasterio reports it as a Python float('nan'), and pystac copies it into the band โ where the schema check passes, because NaN is a float. But NaN is not JSON: Python's json writes the bare token NaN, which strict parsers (JavaScript's JSON.parse, most STAC servers) reject. The raster extension spells it as the string "nan".
Example 2 โ a composite covering a period
period = pystac.Item(
id="ndvi-lewes-2026-04",
geometry=footprint, bbox=[w, s, e, n],
datetime=None,
properties={
"start_datetime": min(it.datetime for it in items).isoformat(),
"end_datetime": max(it.datetime for it in items).isoformat(),
"title": "Sentinel-2 NDVI median composite",
"license": "CC-BY-4.0",
},
)
print(period.properties["start_datetime"], "โ", period.properties["end_datetime"])
print("with the range:", first_error(period.to_dict()))
d = period.to_dict()
del d["properties"]["start_datetime"], d["properties"]["end_datetime"]
print("without it: ", first_error(d))
2026-04-07T11:07:01.195000+00:00 โ 2026-04-30T11:16:55.528000+00:00
with the range: valid
without it: None should not be valid under {'type': 'null'}
datetime=None is legal only when both range properties are present. With datetime null and no range, the schema's error is the unhelpful None should not be valid under {'type': 'null'} โ it means "a null datetime needs a start and an end". The range here runs from the first to the last of the seven scenes, which is the honest period for a composite; April's first and last days would claim observations that are not in it.
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
remote = full.clone()
remote.assets["data"].href = items[0].assets["red"].href # a real COG on S3
remote.add_asset("missing", pystac.Asset(href=items[0].assets["red"].href.replace("B04", "B99"),
media_type=pystac.MediaType.COG))
flipped = full.clone()
flipped.bbox = [e, n, w, s]
print("flipped bbox validates:", first_error(flipped.to_dict()))
for label, candidate in (("as built", full), ("remote hrefs", remote), ("flipped bbox", flipped)):
print(f"{label:13}", audit_item(candidate) or "no problems")
flipped bbox validates: valid
as built no problems
remote hrefs ['asset missing href returns 404']
flipped bbox ['bbox is not west, south, east, north: [0.06643504476266959, 50.924093610421195, -0.10624580077843575, 50.815945958113396]']
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 "this file is laid out for range requests: read a window"; a plain image/tiff makes no such promise, and a client that plans its reads from the catalogue โ which assets to stream, which to fetch whole โ takes the label at its word. Nothing in validation checks that the label matches the file, in either direction.
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.