How to Validate GeoPackage OGC Compliance
Run Python sqlite3 schema checks against the mandatory gpkg_* metadata tables, then confirm geometry encoding and spatial reference integrity with GDAL’s ogrinfo — the combined output gives you a structured pass/fail report suitable for CI/CD pipeline gates before files reach field devices or sync endpoints.
Why This Matters
Field GIS technicians and mobile developers treat GeoPackage as a portable replacement for shapefiles, but its SQLite foundation enforces structural rules that most tools do not surface until a query silently fails. When a file violates the schema contracts described in the GeoPackage Specification Deep Dive, mobile SQLite drivers often fail silently — producing corrupted spatial indexes, missing geometry columns, or broken sync sessions in QField, ArcGIS Field Maps, and custom React Native or Flutter clients.
Validating at ingestion prevents cascading data loss in offline-first workflows. A ten-millisecond check at the pipeline gate is far cheaper than debugging a 500-device field deployment where half the units received a non-compliant container.
Prerequisites
- Python 3.9+ with the standard
sqlite3module (no extra install required) - GDAL/OGR 3.4+ installed system-wide (
ogrinfomust be onPATH); skip on headless environments and substitute thegdal_geometrycheck with a pure-SQL fallback - Familiarity with the four mandatory GeoPackage metadata tables (
gpkg_contents,gpkg_spatial_ref_sys,gpkg_geometry_columns,gpkg_extensions) - A
.gpkgfile produced by GDAL, GeoPandas, or any OGC-compliant tool
Validation Flow at a Glance
The diagram below maps the four checks performed by the script: header identification, mandatory table presence, gpkg_contents data-type enumeration, spatial reference binding, and GDAL WKB geometry inspection.
Primary Method: Python + GDAL Validation Script
The script below combines SQLite schema inspection with GDAL geometry validation. It checks the four mandatory OGC tables, validates gpkg_contents data types, verifies spatial reference bindings, and delegates WKB integrity to ogrinfo. The output is a structured JSON report with a top-level compliant boolean — parse it in CI/CD to block non-compliant files.
# GeoPackage context — OGC 12-128 compliance validation
import sqlite3
import subprocess
import sys
import json
from pathlib import Path
def validate_geopackage(gpkg_path: str) -> dict:
"""Validate OGC GeoPackage compliance using SQLite schema checks + GDAL.
Returns a dict with keys:
file — absolute path
compliant — bool, True only if ALL checks pass
checks — list of individual check results
"""
path = Path(gpkg_path).resolve()
if not path.exists():
return {"file": str(path), "compliant": False, "checks": [
{"check": "file_exists", "status": "FAIL", "error": "File not found"}
]}
report: dict = {"file": str(path), "compliant": True, "checks": []}
try:
with sqlite3.connect(str(path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# ── 1. Mandatory OGC Table Presence ─────────────────────────────
# OGC 12-128r20 §2.1: these four tables must exist in every GPKG.
mandatory = {
"gpkg_spatial_ref_sys",
"gpkg_contents",
"gpkg_geometry_columns",
"gpkg_extensions",
}
cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
existing = {row["name"] for row in cur.fetchall()}
missing = sorted(mandatory - existing)
if missing:
report["compliant"] = False
report["checks"].append({
"check": "mandatory_tables",
"status": "FAIL",
"missing": missing,
})
else:
report["checks"].append({"check": "mandatory_tables", "status": "PASS"})
# ── 2. gpkg_contents Data-Type Enumeration ───────────────────────
# Allowed values are strictly: features | attributes | tiles
cur.execute(
"SELECT table_name, data_type, srs_id FROM gpkg_contents;"
)
contents = cur.fetchall()
if not contents:
report["compliant"] = False
report["checks"].append({
"check": "gpkg_contents",
"status": "FAIL",
"message": "gpkg_contents is empty — no registered layers",
})
else:
valid_types = {"features", "attributes", "tiles"}
invalid = [r["data_type"] for r in contents
if r["data_type"] not in valid_types]
if invalid:
report["compliant"] = False
report["checks"].append({
"check": "gpkg_contents",
"status": "FAIL",
"invalid_types": invalid,
})
else:
report["checks"].append({
"check": "gpkg_contents",
"status": "PASS",
"layer_count": len(contents),
})
# ── 3. Spatial Reference Integrity ───────────────────────────────
# Every srs_id referenced in gpkg_contents must have a row in
# gpkg_spatial_ref_sys. srs_id=0 (undefined) is always permitted.
cur.execute("""
SELECT c.srs_id
FROM gpkg_contents c
LEFT JOIN gpkg_spatial_ref_sys s ON c.srs_id = s.srs_id
WHERE s.srs_id IS NULL AND c.srs_id != 0;
""")
orphan_srs = [r[0] for r in cur.fetchall()]
if orphan_srs:
report["compliant"] = False
report["checks"].append({
"check": "spatial_ref_sys",
"status": "FAIL",
"orphan_srs_ids": orphan_srs,
"message": "srs_id values referenced in gpkg_contents but absent from gpkg_spatial_ref_sys",
})
else:
cur.execute(
"SELECT COUNT(*) FROM gpkg_spatial_ref_sys WHERE srs_id != 0;"
)
count = cur.fetchone()[0]
report["checks"].append({
"check": "spatial_ref_sys",
"status": "PASS",
"defined_srs_count": count,
})
except sqlite3.DatabaseError as exc:
# Not a valid SQLite file at all
report["compliant"] = False
report["checks"].append({
"check": "sqlite_connection",
"status": "FAIL",
"error": str(exc),
})
return report
# ── 4. GDAL WKB Geometry Validation (ogrinfo) ────────────────────────────
# ogrinfo -so -al summarises every layer's geometry type and SRS.
# Any ERROR in stderr signals a malformed WKB blob or driver rejection.
try:
result = subprocess.run(
["ogrinfo", "-so", "-al", str(path)],
capture_output=True,
text=True,
check=True,
timeout=30,
)
if "ERROR" in result.stderr.upper():
report["compliant"] = False
report["checks"].append({
"check": "gdal_geometry",
"status": "FAIL",
"details": result.stderr.strip(),
})
else:
report["checks"].append({"check": "gdal_geometry", "status": "PASS"})
except FileNotFoundError:
report["checks"].append({
"check": "gdal_geometry",
"status": "SKIP",
"message": "ogrinfo not found — install gdal-bin to enable geometry validation",
})
except subprocess.TimeoutExpired:
report["compliant"] = False
report["checks"].append({
"check": "gdal_geometry",
"status": "FAIL",
"message": "ogrinfo timed out — file may be corrupt or excessively large",
})
except subprocess.CalledProcessError as exc:
report["compliant"] = False
report["checks"].append({
"check": "gdal_geometry",
"status": "FAIL",
"details": exc.stderr.strip(),
})
return report
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python validate_gpkg.py <path_to_gpkg>")
sys.exit(1)
output = validate_geopackage(sys.argv[1])
print(json.dumps(output, indent=2))
sys.exit(0 if output["compliant"] else 1)
Step-by-step Walkthrough
1. Install GDAL (if not already present)
On Debian/Ubuntu:
sudo apt install gdal-bin python3-gdal
On macOS with Homebrew:
brew install gdal
Confirm GDAL is on your PATH:
ogrinfo --version
# GDAL 3.8.x, released 2024/...
Python’s sqlite3 ships with CPython 3.9+ and needs no separate install.
2. Run a single-file validation
python validate_gpkg.py data/offline_survey.gpkg
Successful output looks like:
{
"file": "/abs/path/data/offline_survey.gpkg",
"compliant": true,
"checks": [
{"check": "mandatory_tables", "status": "PASS"},
{"check": "gpkg_contents", "status": "PASS", "layer_count": 3},
{"check": "spatial_ref_sys", "status": "PASS", "defined_srs_count": 2},
{"check": "gdal_geometry", "status": "PASS"}
]
}
The script exits with code 0 when compliant and 1 when not — suitable for shell && chains.
3. Integrate into a CI/CD pipeline gate
In a GitHub Actions workflow:
- name: Validate GeoPackage compliance
run: |
python validate_gpkg.py ${{ env.GPKG_PATH }}
Because the script exits non-zero on failure, the step fails and blocks the workflow automatically. In GitLab CI or Airflow DAGs, parse the compliant key from the JSON output if you need finer-grained reporting:
import json, subprocess, sys
result = subprocess.run(
["python", "validate_gpkg.py", "data/field_export.gpkg"],
capture_output=True, text=True
)
report = json.loads(result.stdout)
if not report["compliant"]:
failed = [c for c in report["checks"] if c["status"] == "FAIL"]
raise RuntimeError(f"GeoPackage compliance failed: {failed}")
4. Batch-validate a directory
Wrap the validation function in a thread pool for directory-level runs. GeoPackage files are single-file SQLite databases, making them safe for concurrent read-only checks:
# GeoPackage context — batch directory validation
import concurrent.futures
from pathlib import Path
def validate_directory(directory: str) -> list[dict]:
gpkg_files = list(Path(directory).glob("**/*.gpkg"))
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(validate_geopackage, [str(f) for f in gpkg_files]))
return results
if __name__ == "__main__":
reports = validate_directory("data/")
failures = [r for r in reports if not r["compliant"]]
print(f"{len(failures)} of {len(reports)} files failed compliance checks.")
Verification
After running the script, confirm the report independently with a direct ogrinfo call and a SQL probe:
# Verify layers and SRS with ogrinfo
ogrinfo -so -al data/offline_survey.gpkg
-- GeoPackage context — confirm mandatory table contents
SELECT table_name, data_type, srs_id FROM gpkg_contents;
SELECT srs_id, organization, organization_coordsys_id FROM gpkg_spatial_ref_sys
WHERE srs_id != 0;
Also run an SQLite PRAGMA sanity check — a valid GeoPackage must be a well-formed SQLite 3 file:
sqlite3 data/offline_survey.gpkg "PRAGMA integrity_check;"
# Expected: ok
If integrity_check returns anything other than ok, the file is corrupt at the page level and cannot be repaired by schema fixes alone — rebuild from source data.
Alternative Approaches
CLI-only path: ogr2ogr round-trip
If you do not need a structured JSON report, the fastest single-command check is an ogr2ogr copy to /vsimem/ (GDAL’s in-memory filesystem). GDAL will refuse to read non-compliant files:
ogr2ogr -f GeoJSON /vsimem/test.geojson data/offline_survey.gpkg && echo "OK" || echo "FAIL"
This approach does not report which specific check failed, so it is better suited to quick manual triage than to automated pipelines.
Pure-SQL fallback (no GDAL required)
When deploying to headless environments without GDAL, replace the gdal_geometry check with a direct query against gpkg_geometry_columns. This does not validate WKB blob integrity, but it catches the most common schema-level violations:
-- GeoPackage context — pure-SQL geometry column check
SELECT gc.table_name, gc.column_name, gc.geometry_type_name, gc.srs_id,
CASE WHEN s.srs_id IS NULL THEN 'MISSING_SRS' ELSE 'OK' END AS srs_status
FROM gpkg_geometry_columns gc
LEFT JOIN gpkg_spatial_ref_sys s ON gc.srs_id = s.srs_id;
Rows with srs_status = 'MISSING_SRS' indicate the same orphan-SRS condition the Python script catches in check 3.
Troubleshooting
sqlite3.DatabaseError: file is not a database
Cause: The file at the given path is not a valid SQLite 3 container — it may be a truncated download, a corrupted filesystem write, or an entirely different format (e.g., a GeoJSON file renamed to .gpkg).
Fix: Verify the first 16 bytes of the file contain the SQLite magic string SQLite format 3\000:
hexdump -C data/suspect.gpkg | head -1
# Should start with: 53 51 4c 69 74 65 20 66 6f 72 6d 61 74 20 33 00
If they do not, the file must be regenerated from its source data using ogr2ogr -f GPKG.
missing: ['gpkg_extensions']
Cause: The file was created via a raw SQLite dump, a non-compliant tool, or an older GDAL version (pre-2.0) that did not write the gpkg_extensions table.
Fix: Re-export using a modern GDAL:
ogr2ogr -f GPKG output_compliant.gpkg input_legacy.gpkg
This creates all four mandatory tables automatically. Confirm with:
sqlite3 output_compliant.gpkg ".tables" | grep gpkg_
# gpkg_contents gpkg_extensions gpkg_geometry_columns gpkg_spatial_ref_sys
gdal_geometry: FAIL with ERROR 1: ... Corrupt data
Cause: One or more geometry blobs contain malformed WKB — typically caused by a partial write, an interrupted bulk insert, or incorrect byte-order encoding. Managing transaction scoping and rollback strategies during bulk inserts prevents this class of corruption.
Fix: Identify the affected layer:
ogrinfo -sql "SELECT fid FROM survey_points WHERE ST_IsValid(geom) = 0" \
data/offline_survey.gpkg
Then rebuild the geometries in place using ST_MakeValid() (available in GDAL’s SQLite dialect) or re-export the layer with a spatial filter to exclude null geometries.
Related
- GeoPackage Specification Deep Dive — parent page covering the full OGC 12-128 schema contracts, mandatory table structures, and binary geometry formats this validator targets
- SpatiaLite vs GeoPackage Performance Benchmarks — when to choose each format for offline-sync workloads and what that choice means for your validation strategy
- Managing Spatial Reference Systems in SQLite — deep dive on injecting and querying SRS definitions, directly relevant to fixing orphan
srs_idfailures - Transaction Scoping & Rollback Strategies — how to structure bulk writes so partial failures never leave a GeoPackage in an unvalidatable state
- Securing GeoPackage Files for Field Use — file-permission and encryption considerations that apply after a container passes compliance validation