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

  1. Python 3.10+ with a venv or conda environment activated per contributor
  2. pre-commit>=3.7 installed and available on PATH
  3. GDAL/OGR 3.6+ with PROJ 9+ so fiona and geopandas can open GeoPackage and GeoJSON files (gdal-bin, libgdal-dev on Ubuntu)
  4. Python packages, pinned in the hook config: fiona>=1.9, geopandas>=0.14, pyproj>=3.6, lxml>=5.1, jsonschema>=4.21
  5. Environment variable PRE_COMMIT_HOME set to a shared cache path (e.g. ~/.cache/pre-commit) so hook environments are reused across repositories
  6. Write access to the repository’s .pre-commit-config.yaml and a scripts/ directory to hold the hook entry points
  7. 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"

Pre-commit Flow for Spatial Metadata A staged change enters pre-commit, which filters files by extension and fans them out to three hooks — schema and layer check, CRS declaration check, and license field check. Each hook returns pass or fail; any failure blocks the commit and reports, while all passes allow the commit and the same hooks run again in CI. git add staged type filter Schema & layer check CRS declaration check License field check all pass? Block & report Commit + CI mirror no yes

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