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, andALLOWED_EPSGas 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_id0 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
fionaandpyprojinadditional_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_contentsrecords 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_LAYERSrather 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.
Related
- Pre-commit Hooks for Spatial Metadata — the parent guide covering the full pre-commit configuration for GeoPackage, GeoJSON, and XML metadata
- Spatial Data Schema Linting in CI — the server-side linting architecture this hook feeds into
- Writing a GitHub Actions Workflow for DCAT Validation — a companion CI check for catalog metadata alongside GeoPackage structure
- Policy Enforcement Gates for Data PRs — promote this hook’s CI mirror into a branch-protection required check