Pre-commit Hooks for Spatial Metadata
Most spatial data defects are cheapest to catch on the machine that created them — before a push, before a pull request, before a reviewer ever loads the layer. A missing license column, an undeclared coordinate reference system, or a GeoPackage that silently dropped its attribute table are all trivially detectable at commit time, yet they routinely slip through because the only validation gate lives in CI, minutes away from the mistake. The pre-commit framework closes that gap by running validation the instant a contributor stages a file, and this guide shows how to bend it toward geospatial metadata. It is a focused component of the broader CI/CD Validation & Policy Enforcement for Spatial Data framework: where spatial data schema linting in CI describes the server-side gate, this page builds the client-side one that runs first.
Prerequisites
- Python 3.10+ with a
venvorcondaenvironment activated per contributor pre-commit>=3.7installed and available onPATH- GDAL/OGR 3.6+ with PROJ 9+ so
fionaandgeopandascan open GeoPackage and GeoJSON files (gdal-bin,libgdal-devon Ubuntu) - Python packages, pinned in the hook config:
fiona>=1.9,geopandas>=0.14,pyproj>=3.6,lxml>=5.1,jsonschema>=4.21 - Environment variable
PRE_COMMIT_HOMEset to a shared cache path (e.g.~/.cache/pre-commit) so hook environments are reused across repositories - Write access to the repository’s
.pre-commit-config.yamland ascripts/directory to hold the hook entry points - Branch protection enabled so the CI mirror of these hooks is a required status check
Install the framework and the geospatial dependencies the hooks will need:
# Install pre-commit itself (pipx keeps it isolated from project deps)
pipx install "pre-commit>=3.7"
# Register the git hook scripts in the current repository
pre-commit install
# Install the libraries the local hooks import
pip install \
"fiona>=1.9" \
"geopandas>=0.14" \
"pyproj>=3.6" \
"lxml>=5.1" \
"jsonschema>=4.21"
Concept & Spec Reference
The pre-commit framework is a Git hook manager: on git commit it computes the set of staged files, resolves each configured hook to an isolated environment, filters the file list per hook, and runs the hook’s entry command with the surviving paths appended as arguments. A non-zero exit aborts the commit and prints the hook’s output. For spatial repositories the important insight is that this file-filtering and argument-passing machinery is format-agnostic — it will hand a Python script the exact .gpkg, .geojson, or .xml files a commit touches, leaving the domain logic entirely to you.
Hooks are declared in .pre-commit-config.yaml at the repository root. Each hook is an object under a repos: entry; for organisation-specific spatial checks you use a repo: local entry so the logic lives beside the data it validates. The keys that matter for geospatial hooks are enumerated below.
Hook configuration keys
| Config key | Applies to | Purpose for spatial hooks | Example value |
|---|---|---|---|
id |
hook | Stable identifier used by pre-commit run <id> and CI logs |
gpkg-schema-check |
name |
hook | Human-readable label printed while the hook runs | Validate GeoPackage schema |
entry |
hook | Command invoked; receives filtered file paths as trailing args | python scripts/check_gpkg.py |
language |
hook | Environment provider; python builds a venv with pinned deps |
python |
files |
hook | Regex; only staged paths matching it are passed to the hook | '\.gpkg$' |
types_or |
hook | Identify-based filter combined with files (logical AND) |
[file] |
exclude |
hook | Regex of paths to skip even when files matches |
'^fixtures/' |
additional_dependencies |
hook | Extra pip packages installed into the hook venv | ['fiona>=1.9'] |
pass_filenames |
hook | When true, appends matched paths to entry; false runs once |
true |
always_run |
hook | Run even when no matching file is staged | false |
stages |
hook | Git stages that trigger the hook (pre-commit, pre-push) |
[pre-commit] |
repo |
repos entry | local for in-repo hooks; a URL for shared hook repositories |
local |
The interaction between files, types_or, and pass_filenames is where most spatial hooks succeed or fail. files is a Python regex evaluated against each staged path; types_or uses the identify library’s file-type tags. The two are combined with logical AND, so files: '\.gpkg$' with types_or: [file] matches staged GeoPackage files that identify classifies as regular files. With pass_filenames: true, only those paths reach your script — never the entire tree — which keeps commit-time validation proportional to the change.
Implementation Walkthrough
Step 1 — Author the configuration
The configuration wires three local hooks, each guarding a different metadata concern and each scoped to the file types it understands. Keeping the hooks separate means a CRS failure and a license failure are reported independently, which shortens triage.
Rationale: pinning additional_dependencies guarantees every contributor and the CI runner resolve identical library versions, eliminating the “works on my machine” class of GDAL mismatch.
# .pre-commit-config.yaml
minimum_pre_commit_version: "3.7.0"
default_stages: [pre-commit]
repos:
- repo: local
hooks:
- id: gpkg-schema-check
name: Validate GeoPackage layers and columns
entry: python scripts/check_spatial_metadata.py --mode gpkg
language: python
files: '\.gpkg$'
pass_filenames: true
additional_dependencies:
- "fiona>=1.9"
- "geopandas>=0.14"
- "pyproj>=3.6"
- id: geojson-license-check
name: Enforce license field in GeoJSON
entry: python scripts/check_spatial_metadata.py --mode geojson
language: python
files: '\.geojson$'
pass_filenames: true
additional_dependencies:
- "jsonschema>=4.21"
- id: iso-xml-crs-check
name: Check CRS declaration in ISO XML metadata
entry: python scripts/check_spatial_metadata.py --mode xml
language: python
files: '\.xml$'
exclude: '^tests/fixtures/'
pass_filenames: true
additional_dependencies:
- "lxml>=5.1"
Step 2 — Write the hook entry script
A single dispatching script keeps the three hooks consistent: each --mode selects a validator, but all share the same exit-code contract and error formatting. The entry values above all point at this file.
Rationale: pre-commit treats any non-zero exit as a failure, so the script must aggregate every problem across all passed files and exit once — aborting on the first error would hide later failures and force contributors into slow fix-recommit loops.
#!/usr/bin/env python3
"""scripts/check_spatial_metadata.py — pre-commit entry point for spatial metadata.
Invoked by pre-commit with a --mode flag and the staged files as trailing args:
python scripts/check_spatial_metadata.py --mode gpkg data/a.gpkg data/b.gpkg
Exits 1 if any file fails its checks, 0 otherwise.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
REQUIRED_GPKG_COLUMNS = {"dataset_id", "license", "source_date"}
REQUIRED_LAYERS = {"features"}
ALLOWED_EPSG = {4326, 4269, 26917, 27700}
SPDX_ALLOWLIST = {"ODbL-1.0", "CC-BY-4.0", "CC-BY-SA-4.0", "CC0-1.0", "PDDL-1.0"}
def check_gpkg(path: Path) -> list[str]:
"""Assert required layers, columns, and an approved CRS in a GeoPackage."""
import fiona
from pyproj import CRS
errors: list[str] = []
layers = set(fiona.listlayers(str(path)))
missing_layers = REQUIRED_LAYERS - layers
if missing_layers:
errors.append(f"{path}: missing required layer(s) {sorted(missing_layers)}")
for layer in layers & REQUIRED_LAYERS:
with fiona.open(str(path), layer=layer) as src:
columns = set(src.schema["properties"].keys())
missing_cols = REQUIRED_GPKG_COLUMNS - columns
if missing_cols:
errors.append(f"{path}[{layer}]: missing column(s) {sorted(missing_cols)}")
if src.crs:
epsg = CRS.from_user_input(src.crs).to_epsg()
if epsg not in ALLOWED_EPSG:
errors.append(f"{path}[{layer}]: CRS EPSG:{epsg} not in allowlist")
else:
errors.append(f"{path}[{layer}]: no CRS declared")
return errors
def check_geojson(path: Path) -> list[str]:
"""Assert every GeoJSON feature carries an approved SPDX license value."""
errors: list[str] = []
try:
data = json.loads(path.read_text())
except json.JSONDecodeError as exc:
return [f"{path}: JSON parse error — {exc}"]
features = data.get("features", [])
if not features:
errors.append(f"{path}: FeatureCollection contains zero features")
for idx, feature in enumerate(features):
license_id = feature.get("properties", {}).get("license")
if license_id is None:
errors.append(f"{path}: feature {idx} missing 'license' property")
elif license_id not in SPDX_ALLOWLIST:
errors.append(f"{path}: feature {idx} license {license_id!r} not in allowlist")
return errors
def check_xml(path: Path) -> list[str]:
"""Assert an ISO 19115 XML record declares a reference system identifier."""
from lxml import etree
ns = {
"gmd": "http://www.isotc211.org/2005/gmd",
"gco": "http://www.isotc211.org/2005/gco",
}
errors: list[str] = []
try:
tree = etree.parse(str(path))
except etree.XMLSyntaxError as exc:
return [f"{path}: malformed XML — {exc}"]
code = tree.xpath(
"//gmd:referenceSystemInfo//gmd:code/gco:CharacterString/text()",
namespaces=ns,
)
if not code:
errors.append(f"{path}: no gmd:referenceSystemInfo CRS code found")
return errors
DISPATCH = {"gpkg": check_gpkg, "geojson": check_geojson, "xml": check_xml}
def main() -> int:
parser = argparse.ArgumentParser(description="Spatial metadata pre-commit check")
parser.add_argument("--mode", required=True, choices=sorted(DISPATCH))
parser.add_argument("files", nargs="*", type=Path)
args = parser.parse_args()
checker = DISPATCH[args.mode]
all_errors: list[str] = []
for path in args.files:
if path.exists():
all_errors.extend(checker(path))
if all_errors:
for msg in all_errors:
print(f"ERROR: {msg}", file=sys.stderr)
return 1
print(f"OK: {len(args.files)} {args.mode} file(s) passed metadata checks.")
return 0
if __name__ == "__main__":
sys.exit(main())
Step 3 — Register and dry-run the hooks
Rationale: pre-commit install only rewrites .git/hooks/pre-commit; running pre-commit run immediately afterward confirms the config parses and the hook environments build before a contributor hits a failure mid-commit.
# Install the git hook and build every hook environment up front
pre-commit install
pre-commit install-hooks
# Dry-run all hooks against the whole tree (first run builds venvs)
pre-commit run --all-files
# Run a single hook against staged files only
pre-commit run gpkg-schema-check
Validation & CI Integration
Local hooks are advisory — a contributor can bypass them with git commit --no-verify. The enforcement guarantee comes from running the identical hook set in CI, where nobody can skip it. The policy enforcement gates for data PRs workflow then promotes the CI job to a required status check.
Run every configured hook against the full tree inside GitHub Actions:
# .github/workflows/pre-commit.yml
name: Pre-commit Spatial Metadata
on:
pull_request:
paths:
- '**/*.gpkg'
- '**/*.geojson'
- '**/*.xml'
- '.pre-commit-config.yaml'
- 'scripts/check_spatial_metadata.py'
jobs:
pre-commit:
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install GDAL system libraries
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends gdal-bin libgdal-dev
- name: Cache pre-commit environments
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit
run: pip install "pre-commit>=3.7"
- name: Run all hooks
run: pre-commit run --all-files --show-diff-on-failure
Lock the hook logic behind regression tests so a refactor of the validators cannot silently weaken the gate. The following pytest module exercises the license check with an in-memory fixture:
# tests/test_spatial_metadata_hook.py
import json
from pathlib import Path
from scripts.check_spatial_metadata import check_geojson
def test_missing_license_is_flagged(tmp_path: Path):
fc = {"type": "FeatureCollection",
"features": [{"type": "Feature", "properties": {}, "geometry": None}]}
path = tmp_path / "no_license.geojson"
path.write_text(json.dumps(fc))
errors = check_geojson(path)
assert any("missing 'license'" in e for e in errors)
def test_approved_license_passes(tmp_path: Path):
fc = {"type": "FeatureCollection",
"features": [{"type": "Feature",
"properties": {"license": "CC-BY-4.0"},
"geometry": None}]}
path = tmp_path / "ok.geojson"
path.write_text(json.dumps(fc))
assert check_geojson(path) == []
Derivative & Lineage Management
A pre-commit gate protects the source repository, but spatial pipelines constantly emit derivatives — reprojected GeoPackages, clipped GeoJSON extracts, format-converted deliverables — and those outputs must independently satisfy the same hooks before they are committed. The rule of thumb: any process that writes a new .gpkg, .geojson, or metadata .xml inherits the same layer, CRS, and license obligations as the input.
The most common lineage defect is a license field that is not carried through a transformation. When geopandas reprojects a layer, it faithfully copies attribute columns, but a script that rebuilds a GeoDataFrame from selected columns can drop license or source_date unless they are explicitly retained. Make license propagation an assertion in the derivation code itself, not a hope:
import geopandas as gpd
src = gpd.read_file("data/parcels_osgb.gpkg", layer="features")
dst = src.to_crs(epsg=4326)
# Lineage guarantee: metadata columns survive the transform
for column in ("dataset_id", "license", "source_date"):
assert column in dst.columns, f"derivative dropped {column}"
dst["derived_from"] = "parcels_osgb.gpkg"
dst.to_file("data/parcels_wgs84.gpkg", layer="features", driver="GPKG")
Because the derivative is written into the repository, the gpkg-schema-check hook re-validates its layers, columns, and CRS at the next commit — the derived file passes through exactly the same gate as a hand-authored one, so lineage errors surface at commit time rather than in a downstream consumer. For long-term storage of these derivation records, pair the hooks with metadata artifact retention strategies.
Pitfalls & Resolution Table
| Pitfall | Root Cause | Resolution Strategy |
|---|---|---|
| Hook runs against every file, not just staged spatial ones | pass_filenames omitted or files regex too broad |
Set pass_filenames: true and anchor the regex, e.g. files: '\.gpkg$' |
| Hook never fires on GeoPackage commits | files regex written as glob (*.gpkg) instead of a Python regex |
Use regex syntax '\.gpkg$'; pre-commit does not accept shell globs |
ImportError: fiona inside the hook |
Library available in the project venv but not in the isolated hook env | Add the package to the hook’s additional_dependencies with a pinned floor |
| Local commit passes but CI catches a failure | Contributor bypassed the hook with git commit --no-verify |
Mirror hooks in CI via pre-commit run --all-files as a required status check |
| GeoPackage opens but reports no CRS | Layer written without a spatial reference system entry in gpkg_spatial_ref_sys |
Assign CRS at export (to_file after set_crs/to_crs); fail the hook when src.crs is empty |
| ISO XML check passes on a 2003 record but fails on 2014 | Hook hardcodes the gmd namespace; 2014 uses the mdb/cit namespaces |
Detect the root namespace first and select the matching XPath, or scope the hook via files/exclude |
| First commit after clone is slow, then hangs contributors | Hook environments build on first run inside the commit | Run pre-commit install-hooks during onboarding and cache PRE_COMMIT_HOME in CI |
| License check passes although the value is a free-text string | Validator only checks presence, not membership | Compare against the SPDX_ALLOWLIST set rather than testing for a non-empty value |
Related
- Configuring pre-commit for GeoPackage Schema Checks — a focused hook that opens staged
.gpkgfiles and asserts required layers, columns, and CRS - Writing a GitHub Actions Workflow for DCAT Validation — extend the CI mirror to run DCAT-AP SHACL validation on every pull request
- Spatial Data Schema Linting in CI — the server-side linting architecture these local hooks complement
- Policy Enforcement Gates for Data PRs — turn the CI mirror into a branch-protection gate that blocks non-compliant merges
- CI/CD Validation & Policy Enforcement for Spatial Data — the parent framework covering the full validation pipeline from commit to catalogue