Pre-Commit Hooks for GeoJSON Schema Checks
By the time a broken GeoJSON file reaches continuous integration, the developer has already context-switched away. A pre-commit hook moves the feedback to the moment of the mistake: it inspects the staged file, validates its feature properties against a JSON Schema, checks each geometry for validity, and refuses the commit if anything fails — all in the second between typing git commit and the editor opening. This page builds that hook end to end with the pre-commit framework, a custom Python script, and a .pre-commit-config.yaml, as the local-first layer of Continuous Integration for Spatial Validation.
Prerequisites
- Python 3.10+ and the
pre-commitframework (pip install pre-commit==3.7.*). Confirm withpre-commit --version. - A Git repository with GeoJSON files under version control. The hook only inspects files Git can see as staged changes.
shapely==2.0.*andjsonschema==4.*installed in the same environment the hook runs in. The hook imports both; alanguage: pythonlocal hook withadditional_dependenciespins them (shown in Step 4).- A property contract decided in advance — which feature properties are mandatory and their value ranges. This mirrors the constraint mapping in Mapping Attribute Constraints to GeoJSON Schemas, which this hook operationalizes at commit time.
- A note on scope: hooks are per-clone. A teammate who has not run
pre-commit installhas no hook, and--no-verifybypasses it. Treat the hook as fast local feedback, never as the authoritative gate — that role belongs to CI.
Step-by-Step Procedure
Step 1 — Install pre-commit and register the Git hook
# scripts/bootstrap_precommit.py — one-time developer setup
import subprocess, sys
def main() -> int:
subprocess.run([sys.executable, "-m", "pip", "install", "pre-commit==3.7.*"], check=True)
# Installs .git/hooks/pre-commit so configured checks run on `git commit`
subprocess.run(["pre-commit", "install"], check=True)
print("pre-commit installed; hooks will run on the next commit")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Verification: run the script, then ls .git/hooks/pre-commit. The file must exist and be executable. Running pre-commit run --all-files at this point is a no-op until a hook is configured in Step 4.
Step 2 — Author the JSON Schema for feature properties
Describe the attribute contract as a JSON Schema document committed to the repository. This schema validates the properties object of every GeoJSON feature — not the geometry, which Step 3 handles.
# scripts/write_schema.py — emit schema/parcel.schema.json
import json
from pathlib import Path
SCHEMA = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Parcel feature properties",
"type": "object",
"required": ["parcel_id", "zoning_code", "assessed_value"],
"properties": {
"parcel_id": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{4}$"},
"zoning_code": {"type": "string", "enum": ["R1", "R2", "C1", "C2", "M1"]},
"assessed_value": {"type": "number", "minimum": 0},
},
"additionalProperties": True,
}
Path("schema").mkdir(exist_ok=True)
Path("schema/parcel.schema.json").write_text(json.dumps(SCHEMA, indent=2))
print("wrote schema/parcel.schema.json")
Verification: run the script and confirm schema/parcel.schema.json parses with python -c "import json; json.load(open('schema/parcel.schema.json'))". The pattern and enum constraints are what turn a vague “id should look like this” into an enforceable rule.
Step 3 — Write the custom validation hook script
The hook receives staged GeoJSON paths as arguments, validates each feature’s properties against the schema, and checks geometry validity with Shapely. It exits non-zero the moment any feature fails, which is the signal pre-commit uses to abort the commit.
# hooks/validate_geojson.py
import json
import sys
from pathlib import Path
import shapely
from shapely.geometry import shape
from jsonschema import Draft202012Validator
SCHEMA = json.loads(Path("schema/parcel.schema.json").read_text())
VALIDATOR = Draft202012Validator(SCHEMA)
def validate_file(path: str) -> list[str]:
"""Return a list of human-readable failure messages for one GeoJSON file."""
errors: list[str] = []
try:
doc = json.loads(Path(path).read_text())
except json.JSONDecodeError as exc:
return [f"{path}: not valid JSON — {exc}"]
features = doc.get("features", []) if doc.get("type") == "FeatureCollection" else [doc]
for i, feature in enumerate(features):
fid = feature.get("properties", {}).get("parcel_id", f"index[{i}]")
# 1. Property schema check
for err in VALIDATOR.iter_errors(feature.get("properties", {})):
loc = "/".join(str(p) for p in err.path) or "(root)"
errors.append(f"{path}: feature {fid}: property '{loc}' {err.message}")
# 2. Geometry validity check
geom_dict = feature.get("geometry")
if geom_dict is None:
errors.append(f"{path}: feature {fid}: null geometry")
continue
try:
geom = shape(geom_dict)
except Exception as exc: # malformed coordinates
errors.append(f"{path}: feature {fid}: unreadable geometry — {exc}")
continue
if not shapely.is_valid(geom):
reason = shapely.is_valid_reason(geom)
errors.append(f"{path}: feature {fid}: invalid geometry — {reason}")
return errors
def main(argv: list[str]) -> int:
all_errors: list[str] = []
for path in argv:
all_errors.extend(validate_file(path))
if all_errors:
print("GeoJSON validation failed — commit aborted:\n", file=sys.stderr)
for msg in all_errors:
print(f" - {msg}", file=sys.stderr)
return 1
print(f"GeoJSON validation passed for {len(argv)} file(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
Verification: run the script directly against a fixture: python hooks/validate_geojson.py fixtures/parcels.geojson. On a clean file it prints a pass line and exits 0; point it at a file with a bowtie polygon and it exits 1, naming the feature and the GEOS reason string.
Step 4 — Register the hook in .pre-commit-config.yaml
A local repo entry runs your script with the interpreter pre-commit manages, pinning shapely and jsonschema so the hook is reproducible on any clone. The files regex limits it to GeoJSON.
# scripts/write_precommit_config.py — emit .pre-commit-config.yaml
from pathlib import Path
CONFIG = r'''
repos:
- repo: local
hooks:
- id: geojson-schema-geometry
name: Validate staged GeoJSON schema and geometry
entry: python hooks/validate_geojson.py
language: python
files: \.geojson$
additional_dependencies:
- "shapely==2.0.*"
- "jsonschema==4.*"
pass_filenames: true
'''
Path(".pre-commit-config.yaml").write_text(CONFIG.lstrip())
print("wrote .pre-commit-config.yaml")
Verification: run the script, then pre-commit run --all-files. Stage a deliberately broken GeoJSON (git add) and attempt a commit; the commit must be rejected with your failure messages, and git commit --no-verify must succeed — proving the hook is advisory and that CI remains the enforced gate.
Interpreting Results
The hook’s output is deliberately per-feature. A message like parcels.geojson: feature 0042-1180: invalid geometry — Self-intersection[404732.5 6789234.1] tells the author the file, the feature identifier, and the exact GEOS reason in one line — enough to open the file and fix the vertex without any further tooling. Property failures read the same way, naming the offending property and the schema rule it broke.
Exit codes carry the contract. A 0 means every staged GeoJSON passed both checks and the commit proceeds; a 1 aborts it. Because the same validate_geojson.py can be invoked from a CI step, the local and remote checks produce byte-identical messages, which eliminates the “it passed locally” confusion — if the logic differs, it is a bug in your configuration, not in Git.
Multiple failures print together rather than stopping at the first. This is intentional: a developer fixing GeoJSON wants the full list of what is wrong in one pass, not a whack-a-mole of one error per commit attempt.
Gotchas & Edge Cases
JSON Schema cannot see topology. A schema validates structure and property values, but it has no concept of a self-intersecting ring or an unclosed polygon — those are facts about the coordinate array, not the JSON shape. This is exactly why the hook pairs jsonschema with a Shapely is_valid check; dropping either half leaves a class of defects uncaught.
shape() accepts geometry that is_valid rejects. shapely.geometry.shape() will happily construct a bowtie polygon object from valid GeoJSON coordinates — construction is not validation. Always run is_valid after shape(); do not assume a successfully constructed geometry is topologically sound.
Large GeoJSON files slow the commit. The hook parses each staged file fully. A multi-megabyte FeatureCollection makes git commit feel sluggish. Keep committed GeoJSON small (it should be, if it is fixtures or edits), and validate bulk production extracts in CI rather than at commit time.
CRS is assumed, not checked, in GeoJSON. Per the GeoJSON specification, coordinates are longitude/latitude in a coordinate reference system (CRS) equivalent to EPSG:4326; the format carries no CRS field. If your workflow (wrongly) stores projected coordinates in GeoJSON, is_valid still runs but bounds-based checks become meaningless — normalize to EPSG:4326 before committing, or use GeoPackage instead.
--no-verify and fresh clones silently disable the hook. A teammate who never ran pre-commit install, or anyone using --no-verify, commits with no checks at all. Never treat a green local history as proof of validity; the required CI check described in the parent CI guide is the only enforceable gate.
When to Escalate
The pre-commit hook is the right tool for fast, per-file, per-feature feedback on small staged GeoJSON. Move heavier logic elsewhere when:
- Checks span files or the whole dataset. Overlap detection between parcels, gap analysis, or network connectivity require all features at once and belong in a CI job, not a per-file hook. Run them with Running Spatial Validation in GitHub Actions.
- You need a database engine. Predicate checks at scale run better in PostGIS; provision one with Docker-Based PostGIS Validation Containers in CI rather than embedding a database in a commit hook.
- The rule set grows beyond schema and validity. When you find yourself encoding many interacting business rules, graduate to a real rule engine — see Building Rule Engines with GeoPandas — and call it from both the hook and CI so the logic lives in one place.
Related:
- Continuous Integration for Spatial Validation — the enforced gate that backs up this advisory local hook
- Running Spatial Validation in GitHub Actions — the CI workflow that runs the same checks authoritatively
- Mapping Attribute Constraints to GeoJSON Schemas — how to design the JSON Schema this hook enforces