Validating GeoJSON Against JSON Schema in Python
To validate GeoJSON against a JSON Schema in Python, define a Draft 2020-12 schema for the FeatureCollection — constraining geometry type, coordinate structure, and required properties — then drive it with jsonschema.Draft202012Validator, iterating iter_errors() to report every violation with its JSON path rather than stopping at the first.
Structural validation is the cheapest, earliest quality gate a spatial data pipeline has, and this guide is a focused task within Metadata Schema Validation & Linting, part of the wider Automated Metadata Generation & Schema Mapping pipeline family. GeoJSON is loosely specified enough that malformed files parse as valid JSON while breaking downstream tools: a feature missing its mandated id property, a geometry with a mistyped type, or coordinates supplied as strings instead of numbers. A JSON Schema pins those expectations down and turns “it loaded” into “it conforms,” and reporting errors by path means an author can jump straight to the offending feature instead of bisecting a large file by hand.
Automated Python Implementation
The script below is self-contained. It embeds a Draft 2020-12 schema for a FeatureCollection whose features must carry a Point, LineString, or Polygon geometry and a properties object with required id and name fields. It builds a Draft202012Validator, collects every error, and prints each with its JSON path and the failing rule. It exits non-zero when any feature fails, so it drops directly into a CI gate.
#!/usr/bin/env python3
"""
validate_geojson.py — Validate a GeoJSON FeatureCollection against a JSON Schema.
Usage:
python validate_geojson.py <geojson_file>
Exit codes:
0 — all features conform
1 — one or more validation errors (or the file could not be read)
"""
import json
import sys
from pathlib import Path
from jsonschema import Draft202012Validator
from jsonschema import FormatChecker
# A number in the WGS84 range, reused for lon and lat bounds.
_LON = {"type": "number", "minimum": -180, "maximum": 180}
_LAT = {"type": "number", "minimum": -90, "maximum": 90}
_POSITION = {
"type": "array",
"prefixItems": [_LON, _LAT],
"minItems": 2,
"items": {"type": "number"}, # allow optional elevation as a 3rd element
}
GEOJSON_SCHEMA = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Spatial FeatureCollection",
"type": "object",
"required": ["type", "features"],
"properties": {
"type": {"const": "FeatureCollection"},
"features": {
"type": "array",
"items": {
"type": "object",
"required": ["type", "geometry", "properties"],
"properties": {
"type": {"const": "Feature"},
"geometry": {
"type": "object",
"required": ["type", "coordinates"],
"properties": {
"type": {"enum": ["Point", "LineString", "Polygon"]},
"coordinates": {"type": "array"},
},
"allOf": [
{
"if": {"properties": {"type": {"const": "Point"}}},
"then": {"properties": {"coordinates": _POSITION}},
},
{
"if": {"properties": {"type": {"const": "LineString"}}},
"then": {"properties": {"coordinates": {
"type": "array", "minItems": 2,
"items": _POSITION}}},
},
{
"if": {"properties": {"type": {"const": "Polygon"}}},
"then": {"properties": {"coordinates": {
"type": "array", "minItems": 1,
"items": {"type": "array", "minItems": 4,
"items": _POSITION}}}},
},
],
},
"properties": {
"type": "object",
"required": ["id", "name"],
"properties": {
"id": {"type": ["string", "integer"]},
"name": {"type": "string", "minLength": 1},
},
},
},
},
},
},
}
def validate_geojson(data: dict) -> list[dict]:
"""Return a list of error dicts (path, message, validator) — empty means valid."""
validator = Draft202012Validator(GEOJSON_SCHEMA, format_checker=FormatChecker())
errors = []
for err in sorted(validator.iter_errors(data), key=lambda e: list(e.absolute_path)):
errors.append({
"path": err.json_path, # e.g. $.features[2].properties.name
"message": err.message,
"validator": err.validator, # which keyword failed (required, enum, ...)
})
return errors
def main() -> int:
if len(sys.argv) != 2:
print("Usage: python validate_geojson.py <geojson_file>", file=sys.stderr)
return 1
path = Path(sys.argv[1])
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"Could not read/parse {path}: {exc}", file=sys.stderr)
return 1
errors = validate_geojson(data)
if not errors:
print(f"{path}: valid ({len(data.get('features', []))} features)")
return 0
print(f"{path}: {len(errors)} validation error(s)", file=sys.stderr)
for e in errors:
print(f" {e['path']} — {e['message']} [{e['validator']}]", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
Two schema choices deserve a note. prefixItems is a Draft 2020-12 keyword that constrains array positions individually, which is how the schema enforces [longitude, latitude] order and range while still allowing an optional third elevation element via the trailing items. And the geometry uses if/then inside allOf so that coordinate shape is checked per geometry type — a Polygon needs an array of linear rings of at least four positions, a Point needs a single position — rather than accepting any array under coordinates.
Validation and pipeline integration
Run the validator on representative fixtures, then wire it into a CI gate that blocks non-conformant GeoJSON:
pip install "jsonschema>=4.20"
# Validate a single file; non-zero exit on any error
python validate_geojson.py data/parcels.geojson
# Validate every GeoJSON file in a tree and fail the run on the first bad file
find ./data -name "*.geojson" -print0 \
| xargs -0 -n1 python validate_geojson.py
# .github/workflows/validate-geojson.yml
name: Validate GeoJSON
on:
pull_request:
paths:
- "data/**/*.geojson"
jobs:
validate-geojson:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install jsonschema
run: pip install "jsonschema>=4.20"
- name: Validate all GeoJSON files
run: |
find ./data -name "*.geojson" -print0 \
| xargs -0 -n1 python scripts/validate_geojson.py
A pytest block locks the schema’s behavior so a well-meaning edit cannot loosen it silently:
# test_validate_geojson.py
from validate_geojson import validate_geojson
VALID = {
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-1.2, 51.7]},
"properties": {"id": 1, "name": "Sensor A"},
}],
}
def test_valid_collection_passes():
assert validate_geojson(VALID) == []
def test_missing_required_property_reports_path():
bad = {"type": "FeatureCollection", "features": [{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [0, 0]},
"properties": {"id": 2}, # missing 'name'
}]}
errors = validate_geojson(bad)
assert any(e["validator"] == "required" for e in errors)
assert any("properties" in e["path"] for e in errors)
def test_out_of_range_longitude_flagged():
bad = {"type": "FeatureCollection", "features": [{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [200, 0]},
"properties": {"id": 3, "name": "x"},
}]}
assert validate_geojson(bad) # 200 exceeds max longitude
Long-term compliance best practices
- Pin the schema dialect. Declare
$schemaas Draft 2020-12 and useDraft202012Validatorexplicitly; letting the library auto-detect the dialect makes validation behavior depend on file contents rather than your policy. - Report every error, not the first. Use
iter_errorsand sort by path so authors get a complete list in one run instead of fixing-and-rerunning through a long file. - Version the schema next to the data. Store the JSON Schema in the repository and change it through review; a silent schema relaxation is indistinguishable from a data regression after the fact.
- Pair structural checks with topology checks. JSON Schema cannot detect self-intersections or wrong ring winding; add a
shapelygeometry-validity pass for datasets where topology is compliance-critical. - Constrain coordinate ranges, not just types. Bounding longitude to -180/180 and latitude to -90/90 catches swapped axes and projected coordinates that slipped through as raw numbers.
- Fail the pipeline on any error. Return a non-zero exit code so the CI gate blocks non-conformant GeoJSON at the pull request, before it reaches a catalog or a downstream consumer.
Related
- Metadata Schema Validation & Linting — the parent guide covering XSD, Schematron, and JSON Schema validation across standards
- Automating Metadata Extraction from PostGIS Tables — generating the records that this validation gate checks
- Detecting Invalid Geometries with Shapely in CI — the topology check that complements structural JSON Schema validation
- Automated Metadata Generation & Schema Mapping — the pipeline context for schema-driven quality gates