How to Find Invalid Geometries in a GeoPackage
Run SELECT fid, ST_IsValidReason(geom) FROM layer WHERE geom IS NOT NULL AND ST_IsValid(geom) = 0 with mod_spatialite loaded — or, on a machine without the extension, decode each BLOB with Shapely and test is_valid in Python. Group the results by reason before doing anything else: the list of distinct reasons is short, and it tells you whether this is a data problem or a pipeline problem.
This page belongs to the Geometry Validity & Topology Repair guide, which covers the repair side. Here the scope is narrower: finding the rows, classifying them, and producing something you can act on.
Why This Matters
Invalid geometry does not announce itself. A container holding a thousand self-intersecting polygons opens without complaint, renders on a map, and returns rows from a bounding-box query — because the bounding box of an invalid polygon is perfectly well defined. What breaks is everything past the bounding box: the exact predicate, the area, the overlay. And because those all fail quietly, returning a plausible wrong answer rather than raising, the defect can travel a long way before anyone notices.
The scan is therefore worth running on any container you did not build yourself, and worth running as a gate on the ones you did. It is also the cheapest possible way to find out whether a producer upstream has a problem: a hundred distinct reasons across a hundred rows is data entry, and one reason across four thousand rows is a bug in whatever wrote them.
Prerequisites
- Python 3.9+ with
shapely2.0+ for the extension-free path mod_spatialite5.0+ loadable fromsqlite3for the SQL path- A GeoPackage with at least one registered feature layer
- Read access is sufficient — nothing here writes
Primary Method
# Scan every feature layer in a GeoPackage for invalid geometry
import sqlite3
from collections import Counter
def scan_invalid(gpkg_path: str) -> dict[str, Counter]:
"""
Return {layer_name: Counter(reason -> count)} for every feature layer.
Uses mod_spatialite, so the whole test runs inside SQLite.
"""
conn = sqlite3.connect(f"file:{gpkg_path}?mode=ro", uri=True)
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")
layers = conn.execute("""
SELECT c.table_name, g.column_name
FROM gpkg_contents c
JOIN gpkg_geometry_columns g ON g.table_name = c.table_name
WHERE c.data_type = 'features'
""").fetchall()
results = {}
for table, geom_col in layers:
rows = conn.execute(f"""
SELECT ST_IsValidReason("{geom_col}") AS reason, count(*) AS n
FROM "{table}"
WHERE "{geom_col}" IS NOT NULL
AND ST_IsValid("{geom_col}") = 0
GROUP BY reason
""").fetchall()
if rows:
results[table] = Counter({reason: n for reason, n in rows})
conn.close()
return results
Discovering the layers from gpkg_contents rather than hard-coding them matters: a container that gained a layer since the last scan is exactly the container most likely to have a new problem.
Step-by-Step Walkthrough
1. Enumerate the layers to scan
layers = conn.execute("""
SELECT c.table_name, g.column_name, g.geometry_type_name
FROM gpkg_contents c
JOIN gpkg_geometry_columns g ON g.table_name = c.table_name
WHERE c.data_type = 'features'
ORDER BY c.table_name
""").fetchall()
for t, col, gtype in layers:
print(f"{t}.{col} ({gtype})")
2. Count invalid rows per layer
Start with counts rather than rows. A layer with zero invalid geometries needs no further work, and finding that out costs one query.
-- GeoPackage + mod_spatialite: how bad is it, per layer?
SELECT 'parcels' AS layer,
count(*) AS total,
sum(CASE WHEN ST_IsValid(geom) = 0 THEN 1 ELSE 0 END) AS invalid
FROM parcels WHERE geom IS NOT NULL;
3. Group by reason
The reason string is what turns a count into a diagnosis. GEOS reports a small vocabulary — self-intersection, hole outside shell, nested holes, ring not closed, too few points — and the distribution across those is the signal.
from collections import Counter
reasons = Counter()
for (reason,) in conn.execute("""
SELECT ST_IsValidReason(geom) FROM parcels
WHERE geom IS NOT NULL AND ST_IsValid(geom) = 0
"""):
# strip the trailing coordinate so the reasons group
reasons[reason.split("[")[0].strip()] += 1
for reason, n in reasons.most_common():
print(f"{n:>6} {reason}")
Splitting off the bracketed coordinate is what makes the grouping useful — GEOS appends the location of the fault, so without it every row is its own unique “reason”.
4. Produce a work list with identifiers
Once the shape of the problem is known, extract the rows. Include whatever business key the layer carries, not just the row identifier: fid is meaningless to whoever has to look at the feature on a map.
-- The work list: business key, reason, and where GEOS found the fault
SELECT fid, parcel_ref, ST_IsValidReason(geom) AS reason
FROM parcels
WHERE geom IS NOT NULL AND ST_IsValid(geom) = 0
ORDER BY parcel_ref
LIMIT 50;
5. Scan without the extension
On a machine where mod_spatialite will not load — a restricted interpreter, a minimal container image, a locked-down device — the same scan runs in Python. The only extra step is stripping the GeoPackage Binary header before Shapely sees the bytes.
# Extension-free scan: strip the GPB header, then let Shapely judge
import sqlite3
from shapely import from_wkb
ENVELOPE_LEN = {0: 0, 1: 32, 2: 48, 3: 48, 4: 64}
def gpb_to_wkb(blob: bytes) -> bytes:
if blob[:2] != b"GP":
raise ValueError("not a GeoPackage geometry BLOB")
envelope = ENVELOPE_LEN[(blob[3] >> 1) & 0x07]
return blob[8 + envelope:]
conn = sqlite3.connect("file:field.gpkg?mode=ro", uri=True)
invalid = []
for fid, blob in conn.execute("SELECT fid, geom FROM parcels WHERE geom IS NOT NULL"):
geom = from_wkb(gpb_to_wkb(blob))
if not geom.is_valid:
from shapely.validation import explain_validity
invalid.append((fid, explain_validity(geom)))
print(f"{len(invalid)} invalid of the rows scanned")
explain_validity is Shapely’s equivalent of ST_IsValidReason and returns the same GEOS vocabulary, so a work list produced either way is directly comparable. The header-stripping logic is the same as in How to Read GeoPackage Geometry Blobs in Python.
Verification
Prove the scan can actually detect something before trusting a clean result. Insert a known-bad geometry, confirm the scan finds it, and remove it.
# The scan must reject a geometry you know is invalid
from shapely.geometry import Polygon
from shapely import to_wkb
bowtie = Polygon([(0, 0), (2, 2), (2, 0), (0, 2), (0, 0)])
assert not bowtie.is_valid, "fixture is not actually invalid"
conn.execute("BEGIN IMMEDIATE")
conn.execute(
"INSERT INTO parcels (parcel_ref, geom) VALUES (?, GeomFromWKB(?, 4326))",
("TEST-INVALID", to_wkb(bowtie)),
)
found = conn.execute("""
SELECT count(*) FROM parcels
WHERE parcel_ref = 'TEST-INVALID' AND ST_IsValid(geom) = 0
""").fetchone()[0]
conn.execute("ROLLBACK")
assert found == 1, "the scan did not detect a known-invalid geometry"
print("scan verified")
Rolling back rather than deleting means the container is untouched even if the assertion fails partway.
Alternative Approaches or Edge Cases
Scanning incrementally. On a large layer the full scan is a topology test per row, which is expensive enough that it should not run every cycle. Where the layer carries a change-log version, scan only rows above the last checked watermark — the pattern from Tracking Row Changes for Incremental GeoPackage Sync applies unchanged.
Non-geometry values in a geometry column. SQLite’s typing is per value, so a text value can sit in a BLOB column and ST_IsValid returns NULL rather than 0 for it. Add typeof(geom) = 'blob' to the scan and report anything else separately; it is a different defect with a different fix.
Layers with mixed geometry types. A layer declared GEOMETRY can hold points and lines alongside polygons, and validity is nearly meaningless for the first two. Filter on ST_GeometryType where the layer is mixed, so the work list contains only the rows a repair could act on.
Troubleshooting
sqlite3.OperationalError: no such function: ST_IsValid
Cause: mod_spatialite is not loaded on this connection, or extension loading is disabled in this interpreter build. Fix: Call enable_load_extension(True) then load_extension("mod_spatialite") before the query — see Using sqlite3 with SpatiaLite Functions for platform names, or use the extension-free path above.
ST_IsValidReason returns a different string for every row
Cause: GEOS appends the coordinate of the fault, so the strings are unique by construction. Fix: Split the reason at the opening bracket before grouping, as in step 3.
The scan reports zero invalid rows on a layer that renders wrongly
Cause: The problem is not validity. Ring orientation, duplicate vertices and coordinate precision all produce visibly odd results while passing ST_IsValid. Fix: Check orientation and vertex duplication separately — validity is one of several properties, and the parent guide covers where the boundary lies.
Frequently Asked Questions
How long should a full scan take?
Roughly the cost of reading and decoding every geometry once, plus a topology test per row — so it scales with total vertex count rather than with row count. A layer of simple parcels runs at tens of thousands of rows per second; one of detailed coastline polygons with tens of thousands of vertices each is far slower. If the scan is unexpectedly slow, check vertex counts before suspecting the query.
Should the scan run on a copy?
It does not need to — the scan only reads, and opening with mode=ro guarantees that. Working on a copy is worthwhile for a different reason: it lets you experiment with repairs on the same file you scanned without holding a connection to the live container or risking a stray write.
Can I scan a SpatiaLite container the same way?
Yes, with one change: enumerate the layers from geometry_columns rather than gpkg_contents, since the registries differ. The validity functions themselves are identical, because both containers call the same GEOS. Detecting which registry to read is covered in Reading Spatial Metadata with Python.
Related
- Geometry Validity & Topology Repair — parent guide: what validity requires and how to repair what this scan finds
- How to Repair Self-Intersecting Polygons with Shapely — the fix for the reason this scan reports most often
- How to Read GeoPackage Geometry Blobs in Python — the header stripping the extension-free path relies on
- Data Quality Gates & Monitoring for Field Datasets — turning this scan into something a pipeline enforces