A STAC item is rejected by the validator
On this page
Problem statement
pystac.validation.validate(item) raises, the message is a JSON Schema error several levels deep, and the offending value is at the bottom of a wall of schema text. The errors are almost always one of five things, and each has a one-line fix.
The more dangerous problem is the opposite: items that validate and are still wrong. A STAC validator is a JSON Schema check, so it enforces shape and not meaning. A bounding box whose west is east of its east passes validation and never matches a search.
This guide reads the real error messages — produced by breaking a real Sentinel-2 item from Element 84's Earth Search catalogue one field at a time — fixes each cause, and then covers what validation cannot see. It starts with a surprise: the item as published does not validate either.
Quick answer
try:
pystac.validation.validate_dict(published)
except pystac.errors.STACValidationError as exc:
text = str(exc)
print(text.splitlines()[0]) # which schema failed
print(text.splitlines()[1]) # what was wrong
print("On instance" + text.split("On instance")[-1][:60]) # where
Validation failed for Feature with ID S2C_30UXD_20250619_0_L2A against schema at https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json
's2:sequence' does not match any of the regexes: '^(?!s2:)'
On instance['properties']:
{'created': '2025-06-19T16:51:06.731Z',
The item Earth Search serves for Sentinel-2 tile 30UXD on 19 June 2025 declares the sentinel-2 extension v1.0.0 and carries an s2:sequence property that version of the schema does not allow. Catalogues in production fail validation too; the extension and the data drifted apart. The rest of this page works from a copy with that one extension removed, which validates.
The five failures and their fixes:
| message fragment | cause | fix |
|---|---|---|
does not match '(\+00:00|Z)$' |
a date without a time zone | RFC 3339 with Z |
{…} is not valid under any of the given schemas on properties |
no datetime and no range | set it, or both range properties |
is not valid under any of the given schemas on bbox |
3 or 5 numbers | four (or six for 3D) |
is not of type 'integer', 'null' |
an extension field of the wrong type | correct the type |
'description' is a required property |
type is not Feature, so it was checked as a collection |
check type is Feature |
Read the error's ends, fix datetimes and bboxes
1. Read the first and last lines of the error
The first line names the schema that failed — the core item schema or a specific extension. The last block, after On instance, is the value that failed. Everything between is the schema path.
2. Fix the datetime
STAC 1.0 requires RFC 3339 with an explicit UTC designator:
for value in ("2026-03-14", "2026-03-14T10:30:00", "2026-03-14T10:30:00+01:00", "2026-03-14T10:30Z"):
print(f"{value:28}", explain(mutated(lambda d: d["properties"].__setitem__("datetime", value))))
2026-03-14 {'valid': False, 'schema': 'item.json', 'message': "'2026-03-14' does not match '(\\\\+00:00|Z)$'"}
2026-03-14T10:30:00 {'valid': False, 'schema': 'item.json', 'message': "'2026-03-14T10:30:00' does not match '(\\\\+00:00|Z)$'"}
2026-03-14T10:30:00+01:00 {'valid': False, 'schema': 'item.json', 'message': "'2026-03-14T10:30:00+01:00' does not match '(\\\\+00:00|Z)$'"}
2026-03-14T10:30Z {'valid': True}
A bare date, a time with no zone and a time with a +01:00 offset all fail the same pattern: STAC 1.0's schema accepts only Z or +00:00. 2026-03-14T10:30Z passes — it is not valid RFC 3339, which requires seconds, but the schema checks only the ending. Use datetime.datetime(2026, 3, 14, tzinfo=datetime.timezone.utc) and let pystac serialise it, or write "2026-03-14T00:00:00Z".
3. Fix a missing datetime
print("removed: ", explain(mutated(lambda d: d["properties"].pop("datetime"))))
print("null + a range: ", explain(mutated(lambda d: d["properties"].update(
{"datetime": None, "start_datetime": "2025-06-01T00:00:00Z", "end_datetime": "2025-06-30T23:59:59Z"}))))
removed: {'valid': False, 'schema': 'item.json', 'message': '{…} is not valid under any of the given schemas'}
null + a range: {'valid': True}
The message quotes the whole properties object back and says it matches none of the allowed shapes — the schema is an anyOf: datetime, or a null datetime with both range properties. For a composite, set datetime to null and give both.
4. Fix the bbox length
bbox must have four numbers for 2D or six for 3D, in [west, south, east, north] order:
print("three numbers:", explain(mutated(lambda d: d.__setitem__("bbox", d["bbox"][:3]))))
print("west and east swapped:", explain(mutated(lambda d: d.__setitem__("bbox", [d["bbox"][2], d["bbox"][1], d["bbox"][0], d["bbox"][3]]))))
three numbers: {'valid': False, 'schema': 'item.json', 'message': '[-1.535005, 52.222575, 0.141754] is not valid under any of the given schemas'}
west and east swapped: {'valid': True}
A three-element array is rejected; a reversed one is four numbers and validates — step 8 is where that gets caught.
5. Fix extension field types
An extension field with the wrong type fails only when the extension is declared:
def wrong_epsg(d):
d["properties"]["proj:epsg"] = "EPSG:32630"
def wrong_epsg_undeclared(d):
wrong_epsg(d)
d["stac_extensions"] = [e for e in d["stac_extensions"] if "projection" not in e]
print("declared: ", explain(mutated(wrong_epsg)))
print("undeclared:", explain(mutated(wrong_epsg_undeclared)))
declared: {'valid': False, 'schema': 'schema.json', 'message': "'EPSG:32630' is not of type 'integer', 'null'"}
undeclared: {'valid': True}
In projection extension v1.1, which this item declares, proj:epsg is an integer. The same error shape appears for every extension field — and with the extension undeclared, the same wrong value is simply not checked.
6. Declare the extensions you use
An undeclared extension field is unvalidated, not invalid. Adding proj:epsg to properties without listing the projection extension in stac_extensions passes validation and is silently unchecked — so a typo or a wrong type survives to publication. Use the pystac extension helpers, which add the schema URL for you.
7. Cache the schemas for CI
Validation fetches schemas over HTTPS, and a schema URL that no longer resolves fails the check for reasons unrelated to your item:
print(explain(mutated(lambda d: d.__setitem__("stac_version", "1.0"))))
{'valid': False, 'schema': 'could not be fetched', 'message': 'Error when fetching schema https://raw.githubusercontent.com/radiantearth/stac-spec/v1.0/i'}
A stac_version of 1.0 points pystac at a schema URL that returns 404. In CI that network dependency is also a rate limit; pystac keeps fetched schemas in memory for the session, and its validator accepts a map from schema URLs to local files, which makes the check deterministic and offline.
8. Then run the checks the validator does not
Bounding box order, geometry against the data, asset hrefs, licence, media types.
A readable error report and a correct item
The data used on this page
A Sentinel-2 L2A item from Element 84's Earth Search STAC API — tile 30UXD, 19 June 2025, over the East Midlands (Copernicus Sentinel data) — validated with pystac and jsonschema. mutated() returns a copy of the clean item with one change; explain() reduces a validation error to the schema, the message and whether it passed.
import copy
import json
from pathlib import Path
import pystac
import pystac.validation
import requests
if not Path("item.json").exists():
Path("item.json").write_text(requests.get("https://earth-search.aws.element84.com/v1/collections/"
"sentinel-2-l2a/items/S2C_30UXD_20250619_0_L2A", timeout=60).text)
published = json.loads(Path("item.json").read_text())
clean = copy.deepcopy(published)
clean["stac_extensions"] = [e for e in clean["stac_extensions"] if "sentinel-2" not in e]
def mutated(change):
d = copy.deepcopy(clean)
change(d)
return d
def explain(item_dict):
try:
pystac.validation.validate_dict(item_dict)
return {"valid": True}
except pystac.errors.STACValidationError as exc:
lines = [line for line in str(exc).splitlines() if line.strip()]
messages = [line for line in lines[1:] if not line.startswith(" ") and (" is " in line or "does not match" in line)]
messages = messages or [line for line in lines[1:] if line.startswith("{")]
message = (messages or lines[1:2])[0].strip()
if message.startswith("{"): # a whole object quoted back
message = "{…}" + message[message.rindex("}") + 1:]
return {"valid": False, "schema": lines[0].split("schema at ")[-1].split("/")[-1], "message": message[:90]}
except pystac.validation.GetSchemaError as exc:
return {"valid": False, "schema": "could not be fetched", "message": str(exc)[:90]}
print(pystac.__version__, "|", published["id"], "|", published["stac_version"], "|", len(published["stac_extensions"]), "extensions")
print("clean copy:", explain(clean))
1.15.2 | S2C_30UXD_20250619_0_L2A | 1.0.0 | 8 extensions
clean copy: {'valid': True}
Example 1 — a readable error report
import pystac, json
for mutate, label in [
(lambda d: d["properties"].__setitem__("datetime", "2026-03-14"), "bare date"),
(lambda d: d["properties"].pop("datetime"), "no datetime"),
(lambda d: d.__setitem__("bbox", d["bbox"][:3]), "three-element bbox"),
(lambda d: d.__setitem__("type", "Collection"), "type Collection"),
]:
print(f"{label:20}", explain(mutated(mutate)))
bare date {'valid': False, 'schema': 'item.json', 'message': "'2026-03-14' does not match '(\\\\+00:00|Z)$'"}
no datetime {'valid': False, 'schema': 'item.json', 'message': '{…} is not valid under any of the given schemas'}
three-element bbox {'valid': False, 'schema': 'item.json', 'message': '[-1.535005, 52.222575, 0.141754] is not valid under any of the given schemas'}
type Collection {'valid': False, 'schema': 'collection.json', 'message': "'description' is a required property"}
Running the mutations against your own item template is the fastest way to learn which messages mean what.
Example 2 — build the item with the extension helpers
from pystac.extensions.projection import ProjectionExtension
bare = mutated(lambda d: (d.__setitem__("stac_extensions", []), [d["properties"].pop(k) for k in list(d["properties"]) if ":" in k]))
item = pystac.Item.from_dict(bare)
proj = ProjectionExtension.ext(item, add_if_missing=True)
proj.epsg = 32630
print(item.stac_extensions)
print({k: v for k, v in item.properties.items() if k.startswith("proj")})
print(explain(item.to_dict()))
['https://stac-extensions.github.io/projection/v2.0.0/schema.json']
{'proj:code': 'EPSG:32630'}
{'valid': True}
add_if_missing=True is the flag that stops the silent-unvalidated case: the schema URL goes into stac_extensions, so the field is checked from then on. Note what pystac 1.15 wrote: projection extension v2.0, where the EPSG code became proj:code, a string. The Earth Search item uses v1.1 and proj:epsg. Both are valid; a client that looks for only one of them misses the other, which is a reason to know which version your catalogue pins.
Example 3 — the semantic checks, as a test
import pytest, requests
from shapely.geometry import shape, box
def check_item_is_sane(d):
w, s, e, n = d["bbox"]
assert w <= e, f"bbox west {w} is east of east {e}"
assert s <= n, f"bbox south {s} is north of north {n}"
assert -180 <= w <= 180 and -90 <= s <= 90, "bbox is not in geographic coordinates"
assert shape(d["geometry"]).intersects(box(w, s, e, n)), "geometry does not match bbox"
assert d["properties"].get("license"), "no licence declared"
for key, asset in d["assets"].items():
assert asset.get("type"), f"asset {key} has no media type"
if asset["href"].startswith("http") and key == "thumbnail":
assert requests.head(asset["href"], timeout=20, allow_redirects=True).status_code < 400
return "sane"
for label, d in (("clean item", mutated(lambda d: None)),
("reversed bbox", mutated(lambda d: d.__setitem__("bbox", [d["bbox"][2], d["bbox"][1], d["bbox"][0], d["bbox"][3]])))):
try:
print(f"{label:14} validates: {explain(d)['valid']} | {check_item_is_sane(d)}")
except AssertionError as exc:
print(f"{label:14} validates: {explain(d)['valid']} | {exc}")
clean item validates: True | no licence declared
reversed bbox validates: True | bbox west 0.141754 is east of east -1.535005
The first assertion is the one that earns its keep: the reversed bbox validated and failed the sanity check. The Earth Search item has no license in its properties — its collection declares it — so the licence check here reads the collection's instead when you run it against a whole catalogue.
Verbose errors and undeclared extensions
Why the errors are so verbose
pystac surfaces the underlying jsonschema error, which includes the failing sub-schema and the path through the document. That is genuinely useful once you know to read the first line and the On instance block and skip the middle.
Why an undeclared extension field is worse than a rejected one
stac_extensions is the list of schemas the validator applies. The core item schema permits additional properties, so an undeclared proj:epsg is simply an unknown key: never checked, and potentially ignored by clients that look for declared extensions. A validation error is a fixed bug; an unvalidated field is a bug that ships.
Why the bbox order cannot be validated
JSON Schema describes types and shapes, not relationships between array elements. [10, 50, 5, 55] is four numbers, so it satisfies the schema. The ordering constraint is in the specification's prose, which no validator reads.
Why validation belongs in CI along with the semantic checks
An item is published once and read for years. The five shape errors are caught by the validator; the semantic errors are caught only by assertions you write. Running both on every item before publication is a few seconds and removes the class of problem entirely.
Network validation, types and versions
- Validation is a network call. Cache schemas for CI and offline work.
typemust beFeature. Items are GeoJSON Features; collections are not.stac_versionmust be a released version. A typo fails against no schema at all.- Antimeridian items need the two-bbox convention; a wrapping bbox looks reversed.
- Null geometry is allowed with a bbox in some profiles; check your catalogue's rules.
- Datetimes need seconds.
2026-03-14T10:30Zis not RFC 3339, and the STAC 1.0 schema lets it through. - Collections validate too. Their extents and summaries have their own schema.
- Extension versions matter. The URL pins a version; upgrading changes the rules.
Internal links
- How to build a STAC item for your own raster — building one that passes both checks
- STAC catalogues explained — items, collections and links
- Metadata standards compared: ISO 19115, STAC and Frictionless — when STAC is the right shape
- Metadata extents and dates do not match the data — the semantic errors in more depth
- How to download satellite imagery from a STAC catalogue in Python — what a client does with the item
- How to read a COG from a URL in Python — why the media type matters
- How to test a spatial API with pytest — where these assertions live
- How to standardise dates in spatial data with Python — producing RFC 3339 reliably
FAQ
Why does my STAC datetime fail validation?
STAC requires RFC 3339 with an explicit UTC designator. A bare date fails with '2026-03-14' does not match '(\+00:00|Z)$'. Use 2026-03-14T00:00:00Z.
Can a STAC item have no datetime?
Only if start_datetime and end_datetime are both present. Otherwise the core schema reports 'datetime' is a required property.
Why is my extension field not being validated?
Because the extension is not declared in stac_extensions. Undeclared fields are unknown properties, which the core schema permits and never checks.
What does is not of type 'integer', 'null' mean?
An extension field has the wrong type — most often proj:epsg given as "EPSG:4326" instead of 4326.
Does the validator check my bounding box order?
No. JSON Schema cannot express relationships between array elements, so a reversed bbox validates and then never matches a search. Assert it yourself.
How do I validate without network access?
Use a local schema cache. pystac supports one, and it also makes CI runs deterministic and immune to rate limits.