Configuring pre-commit for GeoPackage Schema Checks

Configuring pre-commit for GeoPackage schema checks means adding a repo: local hook that opens each staged .gpkg file, enumerates its layers and columns with fiona, reads its declared coordinate reference system from the GeoPackage’s own SQLite tables, and fails the commit if any required layer, column, or approved CRS is absent. This is the most reliable way to stop a structurally broken GeoPackage from ever entering the repository.

GeoPackage is a single-file SQLite container, which makes it both convenient and deceptively easy to corrupt: a script can write a .gpkg with the wrong layer name, a dropped attribute column, or an undefined spatial reference system, and nothing about the file extension reveals the problem. This guide is the GeoPackage-specific implementation of the broader Pre-commit Hooks for Spatial Metadata guide, and it slots into the CI/CD Validation & Policy Enforcement for Spatial Data framework as the client-side gate that runs before any server-side check.

Automated Python Implementation

The configuration below registers a single local hook scoped to GeoPackage files. Because pass_filenames is true and the files regex is anchored, the script receives only the staged .gpkg paths a commit touches.

# .pre-commit-config.yaml
minimum_pre_commit_version: "3.7.0"

repos:
  - repo: local
    hooks:
      - id: gpkg-schema-check
        name: Validate GeoPackage layers, columns, and CRS
        entry: python scripts/check_gpkg_schema.py
        language: python
        files: '\.gpkg$'
        pass_filenames: true
        additional_dependencies:
          - "fiona>=1.9"
          - "pyproj>=3.6"

The entry script is self-contained. It uses fiona (GDAL-backed) to enumerate layers and their attribute schema, and the standard-library sqlite3 module to read the GeoPackage’s gpkg_contents and gpkg_geometry_columns registry tables directly — the authoritative source for the declared spatial reference system. Every problem across every passed file is collected before the script exits once, so a contributor sees all failures at once rather than fixing them one commit at a time.

#!/usr/bin/env python3
"""scripts/check_gpkg_schema.py — pre-commit hook for GeoPackage schema checks.

pre-commit invokes this with the staged .gpkg files as trailing arguments:
    python scripts/check_gpkg_schema.py data/parcels.gpkg data/roads.gpkg
Exits 1 if any file is missing a required layer, column, or approved CRS.
"""
from __future__ import annotations

import sqlite3
import sys
from pathlib import Path

import fiona
from pyproj import CRS
from pyproj.exceptions import CRSError

# --- Organisation contract: adjust these to your data dictionary ------------
REQUIRED_LAYERS = {"features"}
REQUIRED_COLUMNS = {"dataset_id", "license", "source_date"}
ALLOWED_EPSG = {4326, 4269, 26917, 27700}


def declared_srs(path: Path) -> dict[str, int]:
    """Read srs_id per geometry-table from the GeoPackage SQLite registry."""
    result: dict[str, int] = {}
    con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
    try:
        rows = con.execute(
            "SELECT table_name, srs_id FROM gpkg_geometry_columns"
        ).fetchall()
        for table_name, srs_id in rows:
            result[table_name] = srs_id
    except sqlite3.Error as exc:
        raise RuntimeError(f"{path}: cannot read gpkg_geometry_columns — {exc}") from exc
    finally:
        con.close()
    return result


def check_gpkg(path: Path) -> list[str]:
    """Return a list of schema-violation messages; empty means the file passes."""
    errors: list[str] = []

    # 1. Layer presence via fiona (GDAL) --------------------------------------
    try:
        layers = set(fiona.listlayers(str(path)))
    except Exception as exc:  # noqa: BLE001
        return [f"{path}: cannot open GeoPackage — {exc}"]

    missing_layers = REQUIRED_LAYERS - layers
    if missing_layers:
        errors.append(f"{path}: missing required layer(s) {sorted(missing_layers)}")

    # 2. Declared CRS via the SQLite registry ---------------------------------
    try:
        srs_by_table = declared_srs(path)
    except RuntimeError as exc:
        return errors + [str(exc)]

    # 3. Per-layer column and CRS checks --------------------------------------
    for layer in sorted(layers & REQUIRED_LAYERS):
        with fiona.open(str(path), layer=layer) as src:
            columns = set(src.schema["properties"].keys())
            missing_cols = REQUIRED_COLUMNS - columns
            if missing_cols:
                errors.append(f"{path}[{layer}]: missing column(s) {sorted(missing_cols)}")

        srs_id = srs_by_table.get(layer)
        if srs_id in (None, 0, -1):
            errors.append(f"{path}[{layer}]: undefined CRS (srs_id={srs_id})")
            continue
        try:
            epsg = CRS.from_epsg(srs_id).to_epsg()
        except CRSError:
            errors.append(f"{path}[{layer}]: srs_id {srs_id} is not a resolvable EPSG code")
            continue
        if epsg not in ALLOWED_EPSG:
            errors.append(f"{path}[{layer}]: CRS EPSG:{epsg} not in allowlist {sorted(ALLOWED_EPSG)}")

    return errors


def main(argv: list[str]) -> int:
    all_errors: list[str] = []
    for arg in argv:
        path = Path(arg)
        if path.exists() and path.suffix == ".gpkg":
            all_errors.extend(check_gpkg(path))

    if all_errors:
        for msg in all_errors:
            print(f"ERROR: {msg}", file=sys.stderr)
        return 1
    print(f"OK: {len(argv)} GeoPackage file(s) passed schema checks.")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

The three checks are deliberately layered. Layer presence is cheapest, so it runs first and short-circuits on a corrupt file. The SQLite registry read is authoritative for the declared CRS — a GeoPackage can carry geometry whose srs_id is 0 (undefined) or -1 (empty), both of which GDAL will happily open but downstream reprojection cannot handle, so the hook rejects them explicitly. Column checks use fiona’s parsed schema rather than a raw PRAGMA table_info, so they stay correct even when GDAL exposes columns differently from the underlying SQLite table.

Validation & pipeline integration

Install the hook and dry-run it against a known-good and a deliberately broken GeoPackage before relying on it:

# Register the git hook and build its isolated environment
pre-commit install
pre-commit install-hooks

# Run the GeoPackage hook against everything currently in the tree
pre-commit run gpkg-schema-check --all-files

# Reproduce exactly what pre-commit runs, by hand, for debugging
python scripts/check_gpkg_schema.py data/parcels.gpkg

# Confirm the file's declared SRS independently with GDAL
ogrinfo -so data/parcels.gpkg features | grep -i srs

Mirror the hook in CI so a --no-verify bypass is still caught before merge. The job below runs on any pull request that touches a GeoPackage or the hook itself:

# .github/workflows/gpkg-schema.yml
name: GeoPackage Schema Check

on:
  pull_request:
    paths:
      - '**/*.gpkg'
      - '.pre-commit-config.yaml'
      - 'scripts/check_gpkg_schema.py'

jobs:
  gpkg-schema:
    runs-on: ubuntu-22.04
    timeout-minutes: 12
    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: Install pre-commit
        run: pip install "pre-commit>=3.7"

      - name: Run GeoPackage schema hook
        run: pre-commit run gpkg-schema-check --all-files --show-diff-on-failure

Lock the validator behind a regression test so future edits cannot weaken it. Building a GeoPackage in the test with fiona keeps the fixture self-contained:

# tests/test_gpkg_schema_hook.py
from pathlib import Path

import fiona
from fiona.crs import from_epsg

from scripts.check_gpkg_schema import check_gpkg

SCHEMA = {"geometry": "Point",
          "properties": {"dataset_id": "str", "license": "str", "source_date": "str"}}


def _write_gpkg(path: Path, epsg: int) -> None:
    with fiona.open(path, "w", driver="GPKG", layer="features",
                    schema=SCHEMA, crs=from_epsg(epsg)) as dst:
        dst.write({"geometry": {"type": "Point", "coordinates": (0.0, 0.0)},
                   "properties": {"dataset_id": "d1", "license": "CC-BY-4.0",
                                  "source_date": "2025-01-01"}})


def test_valid_gpkg_passes(tmp_path: Path):
    path = tmp_path / "ok.gpkg"
    _write_gpkg(path, 4326)
    assert check_gpkg(path) == []


def test_disallowed_crs_is_flagged(tmp_path: Path):
    path = tmp_path / "bad_crs.gpkg"
    _write_gpkg(path, 3857)  # Web Mercator, not in the allowlist
    errors = check_gpkg(path)
    assert any("not in allowlist" in e for e in errors)

Long-term compliance best practices

  • Keep the data contract in one place. Define REQUIRED_LAYERS, REQUIRED_COLUMNS, and ALLOWED_EPSG as module-level constants (or load them from a versioned YAML dictionary) so the same values drive the hook, the CI job, and any documentation — drift between them is a silent compliance gap.
  • Treat srs_id 0 and -1 as failures, not warnings. An undefined CRS is unrecoverable downstream; blocking it at commit time is far cheaper than discovering it when a reprojection produces coordinates in the wrong hemisphere.
  • Pin fiona and pyproj in additional_dependencies. GeoPackage handling changes subtly across GDAL releases; a pinned floor keeps every contributor and the CI runner reading the registry identically.
  • Validate the full SQLite registry periodically. Beyond geometry columns, gpkg_contents records bounding boxes and last-change timestamps; a scheduled deeper check can catch stale extents that per-commit hooks skip for speed.
  • Explode multi-layer GeoPackages deliberately. If your contract expects exactly one geometry layer, assert that layers == REQUIRED_LAYERS rather than a subset, so an accidentally bundled scratch layer is caught.
  • Record every rejected commit. Route hook failures into the same audit stream used by metadata artifact retention strategies so recurring schema violations become measurable rather than anecdotal.