Writing STAC Items from a Lambda Tiling Job
Write the Item inside the tile worker, immediately after the COG upload returns, using an id derived from (scene_id, row_off, col_off) so the object key is deterministic and a retried invocation overwrites rather than duplicates. The Item costs about 3.6 KB of JSON and 40 ms of wall clock on a worker that already spent twenty seconds on pixels, and it carries the two extension blocks that matter for raster: proj:epsg / proj:shape / proj:transform on the Item, and raster:bands on the data asset. The footprint goes into the Item as EPSG:4326 longitude and latitude no matter what the tile’s native CRS is.
Context
The STAC cataloging and metadata publishing page covers the object model and the choice of backend. This page is the one function that runs inside a worker in a fan-out — the tile Lambdas in serverless NDVI tiling from Sentinel-2, or the map-state workers in processing a 10 GB GeoTIFF with Step Functions and Lambda.
The worker is the only process that can write this Item cheaply. It holds the Window it was given, so the tile’s affine transform is one function call away; it just wrote the array, so the nodata count and value range are already in memory; and it knows the source scene id from its own payload. A later crawler would have to re-open every tile to recover all three, which for a 240,000-tile day is 240,000 header requests it does not need to make.
Everything below therefore runs at the tail of an existing handler. Nothing here fans out, and nothing here needs orchestration.
One consequence of that placement is worth stating up front, because it shapes the code: the worker has no global view. It does not know how many sibling tiles exist, whether they succeeded, or what the Collection’s final extent will be. So the handler must never try to update a shared document — appending to a Collection’s Item list from a thousand concurrent workers is a lost-update race with no winner. The only write it performs is to a key nobody else will ever write, and the aggregation is deferred to a single finaliser that runs once the fan-out has drained.
Prerequisites
- Runtime: Python 3.11 on AWS Lambda with
pystac==1.11.0,rasterio==1.4.3andboto3.pystacis pure Python and adds about 1.4 MB unzipped against the 250 MB package limit; if the worker already carriesrasteriofor the tiling work, no new native library is introduced. Layer construction is covered in building rasterio Lambda layers on Amazon Linux 2023. - Memory and timeout: whatever the raster work already requires — typically 1,769 MB and 300 seconds for a 2048 × 2048 window. The Item build adds no measurable memory against the 10,240 MB ceiling.
- IAM:
s3:PutObjecton the catalogue prefix. Nothing else. Keep it off the ingest bucket entirely, in line with IAM security boundaries for cloud GIS. - Input contract: the message carries
scene_id,acquired(an ISO 8601 UTC timestamp), thewindowdict ofcol_off/row_off/width/height, and the source scene’s transform and CRS. - Output layout: assets and Items share a prefix —
s3://out/ndvi-10m-v1/2026/07/14/<item_id>.tifand<item_id>.json— so one deterministic key derivation serves both.
Implementation
The handler below assumes the NDVI array has already been computed by the kernel in computing NDVI per tile with rasterio and NumPy and written to tile_uri. It picks up from there.
# stac_writer.py — build and publish one STAC Item from a tile worker
import json
import os
from datetime import datetime, timezone
import boto3
import numpy as np
import pystac
import rasterio
from pystac.extensions.projection import ProjectionExtension
from pystac.extensions.raster import DataType, RasterBand, RasterExtension
from rasterio.warp import transform_bounds
s3 = boto3.client("s3")
CATALOG_BUCKET = os.environ["CATALOG_BUCKET"]
COLLECTION_ID = "ndvi-10m-v1"
PRODUCT_VERSION = "v1"
def item_id(scene_id: str, row_off: int, col_off: int) -> str:
"""Pure function of the tile's identity. No uuid4, no clock, no request id.
A 10,980 x 10,980 Sentinel-2 10 m band tiled at 2,048 px gives offsets up to
10,240, so five zero-padded digits sort lexicographically as well as
numerically -- which matters when a listing is the fallback enumeration.
"""
return f"{scene_id}_{PRODUCT_VERSION}_r{row_off:05d}_c{col_off:05d}"
def item_key(acquired: datetime, iid: str) -> str:
"""Deterministic key: the same tile always lands in the same place."""
return (f"{COLLECTION_ID}/{acquired:%Y/%m/%d}/{iid}.json")
def build_item(tile_uri: str, scene_id: str, acquired: datetime,
row_off: int, col_off: int, ndvi: np.ndarray,
nodata: float) -> pystac.Item:
with rasterio.open(tile_uri) as ds: # IFD only, no pixel fetch
native_bounds = list(ds.bounds)
epsg = ds.crs.to_epsg()
shape = [ds.height, ds.width]
# rasterio's Affine serialises as (a, b, c, d, e, f); STAC wants the
# first six coefficients of the 3x3 matrix, in that order.
affine = list(ds.transform)[:6]
# STAC geometry and bbox are EPSG:4326. proj:bbox keeps the native one.
w, s, e, n = transform_bounds(f"EPSG:{epsg}", "EPSG:4326", *native_bounds)
iid = item_id(scene_id, row_off, col_off)
item = pystac.Item(
id=iid,
geometry={
"type": "Polygon",
"coordinates": [[[w, s], [e, s], [e, n], [w, n], [w, s]]],
},
bbox=[w, s, e, n],
datetime=acquired.astimezone(timezone.utc),
collection=COLLECTION_ID,
properties={
"gsd": 10,
"constellation": "sentinel-2",
"tile:row_off": row_off,
"tile:col_off": col_off,
},
)
proj = ProjectionExtension.ext(item, add_if_missing=True)
proj.epsg = epsg
proj.shape = shape
proj.transform = affine
proj.bbox = native_bounds
valid = ndvi[ndvi != nodata]
asset = pystac.Asset(
href=tile_uri,
media_type=pystac.MediaType.COG,
roles=["data"],
title="NDVI, float32",
)
item.add_asset("ndvi", asset)
RasterExtension.ext(asset, add_if_missing=True).bands = [
RasterBand.create(
nodata=nodata,
data_type=DataType.FLOAT32,
spatial_resolution=10,
statistics={
"minimum": round(float(valid.min()), 4) if valid.size else None,
"maximum": round(float(valid.max()), 4) if valid.size else None,
"mean": round(float(valid.mean()), 4) if valid.size else None,
},
)
]
# Absolute links: the tree is served over HTTP from a CDN, and a relative
# self link resolved across a redirect boundary builds the wrong URL.
root = f"https://catalog.example.com/stac/{COLLECTION_ID}"
item.set_self_href(f"{root}/{acquired:%Y/%m/%d}/{iid}.json")
item.add_link(pystac.Link("collection", f"{root}/collection.json",
media_type=pystac.MediaType.JSON))
item.add_link(pystac.Link("parent", f"{root}/{acquired:%Y/%m/%d}/catalog.json",
media_type=pystac.MediaType.JSON))
return item
def publish_item(item: pystac.Item, acquired: datetime) -> str:
item.validate() # raises before anything is written
body = json.dumps(item.to_dict(), separators=(",", ":")).encode()
key = item_key(acquired, item.id)
s3.put_object(
Bucket=CATALOG_BUCKET,
Key=key,
Body=body,
ContentType="application/geo+json",
CacheControl="public, max-age=300",
)
return key
Three details in that code do the real work.
item_id() never reads a clock or a random source. That is what makes the write idempotent: the address is derived from the tile’s identity, so the second attempt computes the same key and overwrites the first. There is no conditional put, no lock and no read-modify-write anywhere in the path — the same reasoning behind idempotency and exactly-once spatial processing.
transform_bounds converts the native UTM bounds into longitude and latitude for geometry and bbox, while proj.bbox retains the metres. Skipping the conversion produces an Item that passes schema validation — the schema only counts the numbers — and is spatially wrong by millions of metres.
The raster:bands statistics are computed from the array the worker already holds, which is the whole reason this belongs in the worker rather than in a crawler. Recovering the minimum, maximum and mean of a tile later means downloading and decompressing 16 MB of float32 pixels; here it is three NumPy reductions over an array that is already resident, costing a couple of milliseconds. The same argument applies to the valid-pixel count: it is free at write time and expensive forever after.
item.validate() runs before the put_object, not after. A malformed Item that reaches the bucket is invisible: consumers skip it and the tile silently disappears from the archive. Failing the invocation instead sends the message to a dead-letter queue where somebody sees it, which is what dead-letter queues for failed jobs exist for.
What the Item actually contains
What a retry does
The window that makes retries dangerous is the gap between writing the COG and writing the Item. A worker that times out at Lambda’s 15-minute ceiling in that gap leaves an orphan tile; the retry closes it.
Note the ordering: the asset is written first, the Item second. An Item is a promise that its asset exists, so publishing it first opens a window in which a consumer resolves an href to a 404. The reverse failure — an orphan tile with no Item — is recoverable, because a catalogue rebuild that enumerates the prefix finds it.
Verification
Assert the two properties the schema cannot check: that the id is stable across repeated builds, and that the footprint is in degrees rather than metres.
# verify_item.py — the two invariants that matter
from datetime import datetime, timezone
import numpy as np
from stac_writer import build_item, item_id, item_key
acquired = datetime(2026, 7, 14, 10, 21, 9, tzinfo=timezone.utc)
ndvi = np.full((2048, 2048), 0.42, dtype="float32")
a = build_item("/tmp/t.tif", "S2B_33UUP_20260714", acquired, 4096, 2048,
ndvi, -9999.0)
b = build_item("/tmp/t.tif", "S2B_33UUP_20260714", acquired, 4096, 2048,
ndvi, -9999.0)
assert a.id == b.id, "id is not deterministic — something in it is allocated"
assert item_key(acquired, a.id) == item_key(acquired, b.id)
assert all(-180 <= v <= 180 for v in (a.bbox[0], a.bbox[2])), "bbox in metres"
assert all(-90 <= v <= 90 for v in (a.bbox[1], a.bbox[3])), "bbox in metres"
assert a.properties["proj:epsg"] == 32633
a.validate()
print(a.id)
print(item_key(acquired, a.id))
print([round(v, 4) for v in a.bbox])
print("stable id, degrees bbox, valid against STAC 1.0.0")
Expected output:
S2B_33UUP_20260714_v1_r04096_c02048
ndvi-10m-v1/2026/07/14/S2B_33UUP_20260714_v1_r04096_c02048.json
[14.7743, 48.9021, 15.0521, 49.0862]
stable id, degrees bbox, valid against STAC 1.0.0
The bbox values are single- and double-digit degrees. If they come back as [499980.0, 5390220.0, ...] the reprojection was skipped, and the assertion above is the only thing between that and a catalogue nobody can search. To keep the gate honest, add a case that must fail — build an Item with bbox=native_bounds and assert the check rejects it.
Gotchas and Edge Cases
- A tile of pure nodata still needs an Item — or a decision. Scene edges produce windows with no valid pixels at all, and
valid.min()on an empty array raises. The code above guards withif valid.size else None, but the better answer is usually to skip the tile entirely: do not write the COG, do not write the Item, and record the skip. A catalogue full of empty Items makes every spatial search return tiles with nothing in them. proj:transformmust be the tile’s affine, not the scene’s.rasterio.windows.transform(window, src.transform)shifts the origin to the window’s top-left corner. Reusing the scene transform gives every tile in the scene the same origin, soproj:transformdisagrees withbboxand any client that trusts it reads the wrong pixels. Reading the transform back off the written tile — as the handler above does — makes this mistake structurally impossible.datetimemust be timezone-aware and UTC.pystacserialises a naive datetime without an offset, and consumers then disagree about what it means. Parse the acquisition time with an explicit timezone and call.astimezone(timezone.utc)before it reaches the Item.item.validate()reaches the network on a cold start.pystacfetches the JSON schemas for the core spec and every declared extension the first time it validates, then caches them for the life of the process. In a Lambda that means a few hundred milliseconds added to the first invocation of each execution environment, and a hard failure if the function sits in a VPC with no egress. Vendor the schemas into the deployment package and pointpystac’s validator at the local copies — the same version-pinning discipline as pinning GDAL and PROJ versions across build and runtime.- Setting
Content-Type: application/jsonon an Item is a downgrade, not an error. An Item is GeoJSON;application/geo+jsonis the correct media type and some browsers and clients branch on it. Set it on theput_objectcall, because S3 will otherwise inferbinary/octet-streamfrom a key with no recognised extension.
Frequently Asked Questions
Why derive the Item id from the window instead of using uuid4?
Because the object key is derived from the id, and an idempotent write needs a derived address rather than an allocated one. A tile identified by scene id plus row and column offsets always produces the same key, so a retried invocation overwrites its own earlier Item. With uuid4() the retry writes a second Item pointing at the same asset, and the catalogue permanently records two observations where one exists.
Should the Item be written before or after the COG?
After, always. The Item is a promise that the asset exists, so publishing it first creates a window in which a consumer can resolve an href to a 404. Write the COG, confirm the upload returned, then build the Item from the written file’s own header. If the invocation dies between the two writes you have an orphan tile, which a catalogue rebuild can discover and fix; the reverse ordering leaves a broken Item that only a consumer discovers.
How large is a STAC Item for one tile, and does it affect the Lambda budget?
About 3.6 KB of JSON with the projection and raster extensions attached, and roughly 40 ms of wall clock including the PutObject. Against a worker that already holds two 2048 × 2048 arrays and spends twenty seconds on pixels, that is noise. The only budget it touches meaningfully is the deployment package: pystac adds about 1.4 MB unzipped against the 250 MB Lambda limit.
Related
- STAC Cataloging and Metadata Publishing — the object model, the backend choice and the partitioning this handler feeds
- Serving a Static STAC Catalog from S3 and CloudFront — how the Items this handler writes reach a browser
- Computing NDVI per Tile with Rasterio and NumPy — the kernel that produces the array this Item describes
- Idempotency and Exactly-Once Spatial Processing — why a derived key is the whole retry story
- Partitioning a GeoTIFF into Step Functions Map Tiles — where the row and column offsets in the id come from