Gating Pull Requests on Geospatial Schema Drift Jump to heading
A geospatial dataset’s schema is a contract: downstream joins, tile builds, and portal harvesters all assume the field named parcel_id stays a string and the layer stays in EPSG:2263. A pull request that renames a field, retypes a column, or reprojects a layer breaks that contract silently — the CI passes, the merge lands, and the break surfaces days later in a consumer. This guide builds a check that fails a pull request the moment its schema drifts from a golden snapshot, prints a diff a reviewer can read, and allows the drift through only with an explicit, recorded approval. The drift count it produces is the exact input to schema_drift_count on the CI Validation Scorecards — this page owns the detection; the scorecard owns the aggregate threshold.
Schema drift is not the same as a bad transform. Renaming a field or coercing a type can be entirely legitimate — it is drift without approval that must be blocked. The renaming and coercion rules that make a change intentional are specified in Field Renaming & Type Coercion Rules; when a change legitimately alters the contract, the new shape must be re-expressed for every downstream platform through Cross-Platform Schema Translation. This check is the gate that forces that work to be deliberate.
Prerequisites checklist Jump to heading
Step 1: Capture the golden schema snapshot Jump to heading
The golden snapshot is the authoritative shape the branch is measured against. Serialize field names, types, nullability, and the layer CRS into a stable, sorted JSON structure so that a diff is a pure content comparison and never a key-ordering artifact. Generate it once from the current production dataset and commit it; regenerating it is itself a schema change that must go through the override flow in Step 5.
# schema_snapshot.py — geopandas >=0.14, pyproj >=3.6
from __future__ import annotations
import json
from pathlib import Path
import geopandas as gpd
def extract_schema(source: str | Path) -> dict:
"""Serialize a dataset's field/type/CRS contract into a stable dict."""
gdf = gpd.read_file(source, rows=1) # header only; do not load geometry rows
fields = {
name: {
"dtype": str(dtype),
"nullable": bool(gdf[name].isna().any()) if len(gdf) else True,
}
for name, dtype in gdf.dtypes.items()
if name != gdf.geometry.name
}
crs = gdf.crs
return {
"fields": dict(sorted(fields.items())),
"geometry_field": gdf.geometry.name,
"geometry_type": str(gdf.geom_type.iloc[0]) if len(gdf) else None,
"crs_epsg": crs.to_epsg() if crs else None,
}
def write_golden(source: str | Path, out: Path = Path("schema/golden.json")) -> None:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(extract_schema(source), indent=2, sort_keys=True))
Step 2: Extract the PR-branch schema Jump to heading
Run the identical extractor against the dataset the pull-request branch produces. Using the same function for both sides guarantees the two structures are directly comparable — any difference is real drift, not a difference in how the two schemas were read.
# in the CI job, on the PR branch
from schema_snapshot import extract_schema
pr_schema = extract_schema("build/output.gpkg")
golden = json.loads(Path("schema/golden.json").read_text())
Step 3: Diff the two schemas Jump to heading
Classify every difference into one of five drift kinds: a field added, a field removed, a field retyped, a nullability change, or a CRS change. Each kind carries a different blast radius — a removed field breaks consumers immediately, an added field is usually additive and safe, a CRS change silently offsets every coordinate — so the diff records the kind, not just the fact of a change.
# schema_drift.py — Python 3.10+
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class DriftEntry:
kind: str # FIELD_ADDED | FIELD_REMOVED | FIELD_RETYPED | NULLABILITY | CRS_CHANGED
field: str # field name, or "<crs>" for a CRS change
detail: str # human-readable before -> after
def diff_schema(golden: dict, current: dict) -> list[DriftEntry]:
drift: list[DriftEntry] = []
g_fields, c_fields = golden["fields"], current["fields"]
for name in c_fields.keys() - g_fields.keys():
drift.append(DriftEntry("FIELD_ADDED", name, f"+ {c_fields[name]['dtype']}"))
for name in g_fields.keys() - c_fields.keys():
drift.append(DriftEntry("FIELD_REMOVED", name, f"- {g_fields[name]['dtype']}"))
for name in g_fields.keys() & c_fields.keys():
g, c = g_fields[name], c_fields[name]
if g["dtype"] != c["dtype"]:
drift.append(DriftEntry("FIELD_RETYPED", name, f"{g['dtype']} -> {c['dtype']}"))
if g["nullable"] != c["nullable"]:
drift.append(DriftEntry("NULLABILITY", name, f"{g['nullable']} -> {c['nullable']}"))
if golden["crs_epsg"] != current["crs_epsg"]:
drift.append(DriftEntry(
"CRS_CHANGED", "<crs>",
f"EPSG:{golden['crs_epsg']} -> EPSG:{current['crs_epsg']}",
))
return drift
def count_drift(current: str, golden: str) -> int:
"""Convenience entry point used by the scorecard engine."""
g = json.loads(Path(golden).read_text())
c = json.loads(Path(current).read_text())
return len(diff_schema(g, c))
Step 4: Gate and render the diff Jump to heading
Fail the check on any unapproved drift, and print a diff the reviewer can act on without downloading an artifact. The renderer groups entries by kind and writes GitHub-flavored Markdown to the job summary; the gate function returns the exit code.
# gate_drift.py — Python 3.10+
import sys
from pathlib import Path
from schema_drift import DriftEntry
def render_diff(drift: list[DriftEntry], approved: set[str]) -> str:
if not drift:
return "## Schema drift check — **PASS**\n\nNo drift from the golden schema.\n"
lines = ["## Schema drift check", "", "| Kind | Field | Change | Status |", "| --- | --- | --- | --- |"]
for d in sorted(drift, key=lambda e: (e.kind, e.field)):
status = "approved" if f"{d.kind}:{d.field}" in approved else "**BLOCKED**"
lines.append(f"| `{d.kind}` | `{d.field}` | {d.detail} | {status} |")
return "\n".join(lines) + "\n"
def gate(drift: list[DriftEntry], approved: set[str], summary: Path) -> int:
unapproved = [d for d in drift if f"{d.kind}:{d.field}" not in approved]
summary.write_text(render_diff(drift, approved))
if unapproved:
for d in unapproved:
print(f"::error::unapproved schema drift {d.kind} on {d.field}: {d.detail}")
return 1
return 0
# .github/workflows/schema-drift.yml
name: Schema Drift Gate
on: [pull_request]
permissions:
contents: read
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install "geopandas>=0.14" "pyproj>=3.6" "pyarrow>=14" "pyyaml>=6.0"
- name: Run schema drift gate
run: python -m gate_drift --pr build/output.gpkg --golden schema/golden.json
Step 5: Allow an explicit override Jump to heading
Legitimate schema changes must be able to land — but only deliberately. Permit drift only when two independent signals agree: an approved-schema-change label applied to the pull request by a maintainer, and a matching entry in the committed schema/approved_changes.yaml allowlist. Requiring both prevents an accidental label from waving through a change nobody enumerated, and prevents a stale allowlist entry from applying to a pull request no maintainer reviewed. Every override is recorded in the diff table and, on merge, folded into a regenerated golden snapshot so the next pull request measures against the new contract.
# schema/approved_changes.yaml
approved:
- "FIELD_ADDED:zoning_overlay" # 2026-07: additive column for the new overlay layer
- "CRS_CHANGED:<crs>" # 2026-07: reproject 2263 -> 6539 (survey-foot correction)
# load the allowlist and combine with the PR label (passed via env from Actions)
import os
import yaml
from pathlib import Path
allow = yaml.safe_load(Path("schema/approved_changes.yaml").read_text())["approved"]
label_present = "approved-schema-change" in os.environ.get("PR_LABELS", "").split(",")
# Both must hold for any entry to count as approved.
approved = set(allow) if label_present else set()
Wire the label into the job with PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} in the step’s env. When the label is absent, approved is empty and every drift entry blocks — the allowlist alone can never open the gate.
Verification Jump to heading
Exercise all three outcomes — clean, blocked, and approved — and confirm the exit code and diff match each:
# 1. No drift: gate passes, summary says PASS.
python -m gate_drift --pr golden_copy.gpkg --golden schema/golden.json
echo "exit=$?" # expect 0
# 2. Unapproved drift: gate fails with an ::error:: line per entry.
python -m gate_drift --pr renamed_field.gpkg --golden schema/golden.json
echo "exit=$?" # expect 1
# 3. Approved drift: label + allowlist entry both present -> passes.
PR_LABELS="approved-schema-change" \
python -m gate_drift --pr added_column.gpkg --golden schema/golden.json
echo "exit=$?" # expect 0
The job summary must render the drift table with one row per change and a BLOCKED status on every unapproved entry. Confirm count_drift() returns the same total the scorecard reports — the two must agree, because the scorecard’s schema_drift_count is literally this function’s return value.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Gate fails on a field-order change with no real drift | Golden snapshot not sorted, so key order differs | Regenerate golden.json with sort_keys=True; the extractor already sorts fields — never hand-edit the snapshot. |
| CRS drift not detected | to_epsg() returned None for a custom WKT CRS on one side |
Compare on WKT when to_epsg() is None; a custom CRS with no EPSG code still changes the contract. |
| Every PR blocks after a legitimate merge | Golden snapshot not regenerated after the approved change landed | Regenerate and commit golden.json as part of the approving merge so the next PR measures the new baseline. |
| Override ignored despite the label | Allowlist entry key does not match KIND:field exactly |
Match the key format the diff emits (FIELD_ADDED:zoning_overlay); a typo silently leaves the entry unapproved. |
Retype from int64 to float64 slips through |
Dtype strings compared loosely, or nullability masked the change | Compare exact dtype strings; a widening coercion is still a contract change consumers must be told about. |
Related Jump to heading
- CI Validation Scorecards — consumes this check’s drift count as its
schema_drift_countmetric - Publishing a Schema Conformance Scorecard in GitHub Actions — the workflow that calls
count_driftand folds it into the scorecard - Cross-Platform Schema Translation — re-expressing an approved contract change for every downstream platform
- Field Renaming & Type Coercion Rules — the rules that make a field rename or retype a deliberate, reviewable change rather than drift