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
Five STAC validation errors and the fix for each. the five errorsmessagefixdoes not match '(+00:00|Z)$'RFC 3339 with an explicit Zproperties {…} not valid underany…set datetime, or both rangepropertiesbbox not valid under any…four numbers, or six for 3Dis not of type 'integer', 'null'proj:epsg (v1.1) is an integer'description' is a requiredpropertytype must be FeatureRead the first line and the "On instance" block; skip the middle spatialworkflow.io
All five are shape errors; the semantic ones are underneath.

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.

What a STAC validator checks against the errors that ship anyway. the floor and the gapcaught by the schema✓missing required properties✓wrong types✓the datetime pattern✓bbox lengthships cleanly✕a reversed bounding box✕an undeclared extension field✕a 404 asset href✕a projected bboxAn undeclared extension field is never checked at all spatialworkflow.io
Everything on the right validates cleanly and breaks a catalogue.

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.

The same field with and without its extension declared. declaring changes everythingundeclaredstac_extensions: []proj:epsg: "EPSG:4326"validates ✓never checkeddeclaredprojection/v1.1.0proj:epsg: "EPSG:4326"rejected ✗type enforcedA rejection is a fixed bug; an unchecked field is a bug that ships spatialworkflow.io
Declaring the extension is what turns a free-form property into a checked one.

Network validation, types and versions

  • Validation is a network call. Cache schemas for CI and offline work.
  • type must be Feature. Items are GeoJSON Features; collections are not.
  • stac_version must 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:30Z is 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.

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.