Validating GeoJSON Against a JSON Schema in Python
TL;DR: Compose a draft 2020-12 JSON Schema that enforces both the structural GeoJSON shape (a type of FeatureCollection, a features array, and each feature’s geometry and properties) and your own attribute contract (required fields, types, enums). Then instantiate jsonschema.Draft202012Validator and iterate iter_errors(instance) to collect every failure — reporting each one’s json_path and message — instead of aborting on the first. JSON Schema cannot check geometry semantics such as ring winding order or self-intersection; delegate those to shapely.
The Core Challenge
A GeoJSON FeatureCollection fails validation for two very different classes of reason, and a robust ingest pipeline has to catch both without stopping early. The first class is structural: a feature is missing its geometry, a coordinate is a string instead of a number, or the document’s top-level type is misspelt. The second class is contractual: the geometry is perfectly well-formed GeoJSON, but the properties object omits a required attribute, carries the wrong type, or uses a category value outside your controlled vocabulary. Both must be reported against the exact location in the document so a data publisher can fix the source file in one pass.
The jsonschema library expresses both classes in a single JSON Schema, but it has a trap for newcomers: the convenience function jsonschema.validate() raises on the first ValidationError and returns nothing further. Feed it a file with twelve broken features and it tells you about one. For catalogue ingest — where you want to hand the publisher a complete defect list — you must instead build a Draft202012Validator and walk iter_errors(), which yields one ValidationError object per violation. Each object exposes a json_path (a JSONPath string pinpointing the node) and a human-readable message.
There is a hard boundary to what JSON Schema can enforce. It validates JSON structure — types, required keys, array cardinality, enumerations, numeric ranges — but it has no notion of geometry. It cannot tell you that a polygon’s outer ring runs clockwise when the GeoJSON right-hand rule demands counter-clockwise, nor that a ring crosses itself. Those are topological facts that need a geometry engine such as shapely. The pattern below runs JSON Schema first as a cheap structural gate, then escalates surviving features to shapely for the semantic checks.
The composed schema is the linchpin, so build it first. Attribute rules for spatial records rarely stand alone — they usually derive from a metadata profile such as the one described in Implementing ISO 19115 Metadata Standards, whose mandatory elements map directly onto the required properties you encode here.
Production-Ready Code
The script below defines a single self-contained schema. Reusable geometry definitions live under $defs and are pulled into the feature schema with $ref. The properties sub-schema encodes the attribute contract: a required id string, a required category restricted to an enum, and a numeric elevation. Validation runs through Draft202012Validator.iter_errors, and every error is reported by json_path.
"""
validate_geojson.py — structural + property validation of a GeoJSON
FeatureCollection with the jsonschema library (JSON Schema draft 2020-12).
Dependencies (pip install):
jsonschema>=4.20
"""
from __future__ import annotations
from jsonschema import Draft202012Validator
# ---------------------------------------------------------------------------
# 1. Reusable geometry sub-schemas (referenced from the feature schema)
# ---------------------------------------------------------------------------
# A GeoJSON position is [longitude, latitude] with an optional elevation.
POSITION = {
"type": "array",
"minItems": 2,
"maxItems": 3,
"items": {"type": "number"},
}
GEOJSON_SCHEMA = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://spatialdatapublishing.com/schemas/site-features.json",
"title": "Site FeatureCollection",
"type": "object",
"required": ["type", "features"],
"properties": {
"type": {"const": "FeatureCollection"},
"features": {
"type": "array",
"items": {"$ref": "#/$defs/Feature"},
},
},
"additionalProperties": False,
"$defs": {
# --- geometry primitives ------------------------------------------
"Point": {
"type": "object",
"required": ["type", "coordinates"],
"properties": {
"type": {"const": "Point"},
"coordinates": POSITION,
},
"additionalProperties": False,
},
"LineString": {
"type": "object",
"required": ["type", "coordinates"],
"properties": {
"type": {"const": "LineString"},
"coordinates": {
"type": "array",
"minItems": 2,
"items": POSITION,
},
},
"additionalProperties": False,
},
"Polygon": {
"type": "object",
"required": ["type", "coordinates"],
"properties": {
"type": {"const": "Polygon"},
# array of linear rings; each ring is >= 4 positions
"coordinates": {
"type": "array",
"minItems": 1,
"items": {
"type": "array",
"minItems": 4,
"items": POSITION,
},
},
},
"additionalProperties": False,
},
# --- feature-level property contract (your controlled attributes) --
"Properties": {
"type": "object",
"required": ["id", "category"],
"properties": {
"id": {"type": "string", "minLength": 1},
"category": {
"type": "string",
"enum": ["survey", "borehole", "monument"],
},
"elevation": {"type": "number"},
},
"additionalProperties": False,
},
# --- a Feature ties a geometry to its properties ------------------
"Feature": {
"type": "object",
"required": ["type", "geometry", "properties"],
"properties": {
"type": {"const": "Feature"},
"geometry": {
"oneOf": [
{"$ref": "#/$defs/Point"},
{"$ref": "#/$defs/LineString"},
{"$ref": "#/$defs/Polygon"},
],
},
"properties": {"$ref": "#/$defs/Properties"},
},
"additionalProperties": False,
},
},
}
def validate_feature_collection(instance: dict) -> list[dict[str, str]]:
"""
Validate a GeoJSON FeatureCollection and return EVERY error.
Returns a list of {"path": <json_path>, "message": <str>} dicts,
sorted by document location so the report is deterministic.
"""
validator = Draft202012Validator(GEOJSON_SCHEMA)
errors = sorted(
validator.iter_errors(instance),
key=lambda err: err.json_path,
)
return [{"path": err.json_path, "message": err.message} for err in errors]
if __name__ == "__main__":
sample = {
"type": "FeatureCollection",
"features": [
{ # valid feature
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-3.19, 55.95]},
"properties": {"id": "S-001", "category": "survey", "elevation": 47.0},
},
{ # bad category enum + elevation wrong type
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-3.20, 55.94]},
"properties": {"id": "S-002", "category": "well", "elevation": "high"},
},
{ # missing required 'id' + coordinate is a string
"type": "Feature",
"geometry": {"type": "Point", "coordinates": ["-3.21", 55.93]},
"properties": {"category": "borehole"},
},
],
}
problems = validate_feature_collection(sample)
if not problems:
print("VALID: FeatureCollection satisfies the schema.")
else:
print(f"INVALID: {len(problems)} error(s) found\n")
for p in problems:
print(f" {p['path']}\n {p['message']}\n")
Step-by-Step Walkthrough
Positions as a shared definition. POSITION is an ordinary Python dict spliced into every geometry via variable reference. It fixes the array to two or three number items — longitude, latitude, and an optional elevation — so a coordinate written as ["-3.21", 55.93] fails at the item level with a type error rather than being silently accepted. Keeping it in one place means a change to the position rule propagates to Point, LineString, and Polygon at once.
Geometry sub-schemas under $defs. Each primitive is a self-contained object schema with a const on its type discriminator and additionalProperties: False so unexpected keys are rejected. The Polygon definition encodes the linear-ring rule that JSON Schema can express: an array of rings where each ring holds at least four positions. What it deliberately does not encode — ring closure and winding — is left to shapely.
$ref and draft 2020-12 resolution. The Feature schema references the primitives with fragment pointers such as {"$ref": "#/$defs/Point"}. Because the root declares $schema as the draft 2020-12 dialect and we instantiate Draft202012Validator, these internal references resolve against the root document with no external resolver, registry, or network fetch. The oneOf on geometry accepts exactly one of the three primitive shapes; the const discriminators keep the branches mutually exclusive so a valid geometry matches precisely one.
The property contract. Properties is where domain rules live: id is a required non-empty string, category is constrained to an enum of controlled terms, and elevation must be a number when present. This is the layer you tailor per catalogue — the same technique used when hardening records for a downstream store such as STAC Catalog Publishing for Raster Archives, where item properties carry their own required fields and vocabularies.
Collecting every error. validate_feature_collection never calls the one-shot jsonschema.validate(). It builds a Draft202012Validator once and calls iter_errors(instance), which is a generator yielding one ValidationError per violation across the whole document. Sorting by err.json_path groups errors by location and makes output deterministic for tests and diffs. Each error is reduced to its json_path (a JSONPath string like $.features[1].properties.category) and its message.
Verification
Run the script directly. The valid first feature produces no output; the two broken features produce one line per violation, each anchored to its JSONPath location.
python validate_geojson.py
Expected output:
INVALID: 4 error(s) found
$.features[1].properties.category
'well' is not one of ['survey', 'borehole', 'monument']
$.features[1].properties.elevation
'high' is not of type 'number'
$.features[2].geometry.coordinates[0]
'-3.21' is not of type 'number'
$.features[2].properties.id
'id' is a required property
Every defect is reported in one pass, and each json_path maps straight onto the offending node in the source file. This all-errors behaviour is what distinguishes a usable ingest report from validate(), which would have surfaced only the first line. For a companion approach on the XML side of the catalogue, see the broader Schema Validation for Spatial Records guide.
Gotchas & Edge Cases
Why does jsonschema.validate only report one error when my GeoJSON has several problems?
The convenience function jsonschema.validate() raises on the first ValidationError it encounters and stops — it is built for the pass/fail case, not for reporting. To collect every failure, build a Draft202012Validator instance and iterate over iter_errors(instance), which yields one ValidationError per violation. Sort the results by json_path so the report is deterministic and grouped by location, exactly as validate_feature_collection does above.
How do I reference geometry sub-schemas in a draft 2020-12 schema?
Place each reusable geometry definition under the $defs keyword and point to it with a $ref such as {"$ref": "#/$defs/Point"}. Because the schema declares $schema as the draft 2020-12 dialect and Draft202012Validator is used, the internal fragment references resolve against the root document without any external resolver or network fetch. Avoid the older definitions keyword and the RefResolver class — both are legacy paths that the 2020-12 validator does not need.
Can JSON Schema check polygon winding order or detect self-intersecting geometry?
No. JSON Schema validates JSON structure — types, required keys, array shapes, enumerations and numeric ranges — but it cannot express geometric semantics. The GeoJSON right-hand-rule winding order, ring closure (first position equal to last), and self-intersection are topological properties that require a geometry engine. Load each ring into shapely with Polygon(coords), test geom.is_valid to catch self-intersection, and compare geom.exterior.is_ccw against the right-hand-rule expectation. Run this only on features that already passed the JSON Schema gate, so shapely never sees malformed coordinate arrays.
What does error.json_path return and how is it different from error.absolute_path?
error.json_path returns a JSONPath string such as $.features[2].properties.category that you can paste into a JSONPath tool to locate the offending value. error.absolute_path is a deque of the same path segments — object keys and array indices — if you prefer to walk the instance programmatically. Both point at the same node; json_path is the human-readable form to put in reports and logs, while absolute_path is convenient when you want to reach into the parsed document and correct the value in code.
Why do my oneOf geometry errors look noisy and unhelpful?
When a geometry fails, oneOf reports that the value matched none of the branches and, depending on how deep the failure is, may surface sub-errors from every branch it tried. Rely on the const type discriminators to keep branches mutually exclusive, then use the best_match helper from jsonschema.exceptions to pick the most relevant sub-error per feature. Alternatively, pre-dispatch on the geometry’s type value in Python and validate against the single matching sub-schema, which yields a much tighter json_path and message.
Back to Schema Validation for Spatial Records
Related:
- Schema Validation for Spatial Records — the structural and semantic validation patterns that underpin catalogue ingest
- Implementing ISO 19115 Metadata Standards — the mandatory metadata elements that shape your required GeoJSON property contract
- STAC Catalog Publishing for Raster Archives — item property vocabularies and required fields for raster catalogue records