Diagnosing Invalid Geometry with ST_IsValidDetail

ST_IsValid tells you that a geometry is broken. ST_IsValidDetail tells you what is broken and where — a reason string, a point marking the defect, and a validity flag — which is the difference between a count of failures and a repair plan. This guide builds the triage table around it: screening cheaply, expanding only the failures, materialising defect points you can map, and grouping by reason so repair strategy is decided per defect class rather than per feature. It is the PostGIS diagnosis path for the checks described in Geometry Validity Checks for Vector Data.

Prerequisites

  • PostGIS 3.2+ on PostgreSQL 14+, with GEOS 3.10 or later. Reason strings changed wording across GEOS versions, so record the version with your results: SELECT postgis_full_version();
  • A geometry column with a spatial index — not for the validity check itself, which is a full scan, but for everything you will do with the results.
  • Write access to a schema for the triage table. The triage output is data, and it should live somewhere queryable rather than in a psql scrollback buffer.
  • A decision on the ESRI validity flag before you start counting, because it materially changes the numbers.

Step-by-Step Procedure

Step 1 — Screen with the cheap boolean

-- How much of the layer is affected, before doing any detailed work.
SELECT count(*) FILTER (WHERE NOT ST_IsValid(geom)) AS invalid,
       count(*)                                     AS total,
       round(100.0 * count(*) FILTER (WHERE NOT ST_IsValid(geom)) / count(*), 3)
         AS invalid_pct
FROM parcels;

Verification: if invalid_pct is above a few percent, something systematic happened — a bad import, a failed overlay, a coordinate precision change. Investigate the source before triaging individual features.

From a table of parcels to five repair decisionsFunnel from 214,000 parcels through 523 invalid geometries, expanded into reason classes and reduced to five repair decisions — 500 automatable and 23 requiring a steward.Parcels in the table214,000 — full scan by ST_IsValidInvalid geometries523 — 0.24% of the layerExpanded with detail523 reasons and defect pointsGrouped by reason class5 classesRepair decisions500 automatable · 23 to a stewardThe grouping is what turns 523 broken features into five decisions — which is the whole purpose of reading the reason rather than the boolean.
Triage is a reduction: five decisions are actionable, five hundred and twenty-three findings are not.

Step 2 — Expand only the failures

-- ST_IsValidDetail returns a composite: (valid, reason, location).
CREATE TABLE qa.invalid_parcels AS
SELECT
  p.parcel_id,
  ST_GeometryType(p.geom)          AS geom_type,
  ST_NPoints(p.geom)               AS vertices,
  ST_Area(p.geom)                  AS area_m2,
  d.reason,
  d.location                       AS defect_point,
  split_part(d.reason, '[', 1)     AS reason_class
FROM parcels p
CROSS JOIN LATERAL ST_IsValidDetail(p.geom) AS d
WHERE NOT ST_IsValid(p.geom);          -- screen first; the lateral only runs on failures

CREATE INDEX ON qa.invalid_parcels USING GIST (defect_point);

Verification: the row count must equal the invalid count from Step 1. A mismatch means the screen and the detail disagree, which happens if the geometry column changed between the two queries — run them in one transaction on a busy table.

Step 3 — Group by reason to plan the work

SELECT reason_class,
       count(*)                                  AS features,
       round(avg(vertices))                      AS avg_vertices,
       round(sum(area_m2)::numeric, 1)           AS total_area_m2,
       min(parcel_id)                            AS example_id
FROM qa.invalid_parcels
GROUP BY reason_class
ORDER BY features DESC;

Typical output on a cadastral layer looks like this:

reason_class features avg_vertices interpretation
Self-intersection 412 64 Digitising or overlay artefacts — automatable repair
Ring Self-intersection 88 210 Complex boundaries — repair, then re-check part count
Hole lies outside shell 14 96 Clip error upstream — review before repairing
Interior is disconnected 6 340 Structural; repair will split the feature
Too few points in geometry component 3 3 Degenerate — delete or rebuild, never repair

Verification: the grouping is what turns 523 broken features into five decisions. Anything with a handful of features and a high vertex count is worth looking at individually; anything with hundreds of features and a common reason is a batch job.

Step 4 — Map the defect points

-- Export the defect locations for visual review in QGIS.
COPY (
  SELECT parcel_id, reason_class, ST_AsGeoJSON(defect_point) AS geojson
  FROM qa.invalid_parcels
) TO '/tmp/defect_points.csv' WITH CSV HEADER;

-- Or ask the spatial question directly: are the defects clustered?
SELECT reason_class,
       count(*) AS defects,
       ST_AsText(ST_Centroid(ST_Collect(defect_point))) AS centroid,
       round(ST_Area(ST_ConvexHull(ST_Collect(defect_point)))::numeric / 1e6, 2)
         AS spread_km2
FROM qa.invalid_parcels
GROUP BY reason_class;

Verification: a small spread_km2 for a reason class means the defects are concentrated — usually one digitising session, one map sheet, or one overlay operation. That is a much more useful finding than the feature count, because it points at a cause rather than a symptom.

Step 5 — Repair per reason, with a guard

-- Deterministic classes: repair, verify, and record what changed.
WITH candidates AS (
  SELECT parcel_id FROM qa.invalid_parcels
  WHERE reason_class IN ('Self-intersection', 'Ring Self-intersection')
),
repaired AS (
  SELECT p.parcel_id,
         p.geom                                            AS before_geom,
         ST_CollectionExtract(ST_MakeValid(p.geom), 3)     AS after_geom
  FROM parcels p JOIN candidates c USING (parcel_id)
)
UPDATE parcels p
SET geom = r.after_geom
FROM repaired r
WHERE p.parcel_id = r.parcel_id
  AND ST_IsValid(r.after_geom)
  AND abs(ST_Area(r.after_geom) - ST_Area(r.before_geom))
      / NULLIF(ST_Area(r.before_geom), 0) < 0.001;    -- area-loss guard: 0.1%

-- Anything that failed the guard stays broken and stays visible.
SELECT parcel_id, reason_class FROM qa.invalid_parcels
WHERE parcel_id NOT IN (SELECT parcel_id FROM parcels WHERE ST_IsValid(geom));

Verification: re-run Step 1. The invalid count should fall by the number of repaired features and no further; if it falls by more, something repaired features that were not in the candidate set, and the audit trail is incomplete. The guard and the audit pattern here follow Repairing Invalid Geometries with ST_MakeValid.

Interpreting Results

Reason class What GEOS found Repair strategy
Self-intersection The exterior boundary crosses itself ST_MakeValid; expect a multipolygon
Ring Self-intersection An interior ring crosses itself ST_MakeValid; check the resulting part count
Hole lies outside shell An interior ring is outside its exterior Review — usually an upstream clip error
Holes are nested One hole inside another Review; repair changes the represented area
Interior is disconnected The polygon interior is split by touching rings Repair splits the feature; confirm that is acceptable
Too few points in geometry component A ring with fewer than four coordinates Delete or rebuild; there is nothing to repair
Duplicate Rings Two identical rings in one geometry Deduplicate before repair

The distinction that governs everything is deterministic versus ambiguous. A self-intersection has one obvious repair and no judgement involved. A hole outside its shell does not: the correct fix might be to delete the hole, move it, or reject the feature, and each answer changes the area. Deterministic reasons belong in an unattended batch; ambiguous ones belong in a steward’s queue, per the routing model in Automated Geometry Remediation.

Repair strategy by determinism and consequenceQuadrant plotting invalid-geometry reason classes by how deterministic the repair is against how much the repair changes the represented area. Self-intersections repair deterministically with almost no area change; holes outside shells and disconnected interiors change the represented area and need a decision.Automateone obvious fixAutomate with a guardfix is clear, area movesSteward decidesno single right answerEscalatechanges what the data claimsself-intersectionring self-intersectionhole outside shellinterior disconnecteddegenerate ringArea change caused by the repair →Repair is deterministic →The vertical axis decides whether a pipeline may act unattended; the horizontal axis decides how tight the area-loss guard has to be.
Determinism and area impact are separate questions, and both have to be answered before a repair runs unattended.

Gotchas & Edge Cases

Only one defect is reported per geometry. GEOS stops at the first problem it finds, so a feature with three self-intersections reports one location. Repair and re-check in a loop rather than assuming one pass clears everything.

Two validity interpretations, two different defect countsGrid of four geometry cases showing whether each is invalid under the OGC interpretation and under the ESRI-compatible flag: a bowtie, a self-touching ring, a hole touching the shell at a point, and a degenerate ring.OGC (default)ESRI flag = 1BowtieinvalidinvalidSelf-touching ringinvalidvalidHole touching shell at a pointinvalidvalidDegenerate ringinvalidinvalidData exported from ArcGIS frequently contains the middle two by design. Choose one interpretation for the whole programme and record it, because switching changes the defect count overnight.
The flag is not a formatting option — it changes which geometries count as defects at all.

The ESRI flag changes the answer, not just the wording. ST_IsValidDetail(geom, 1) accepts self-touching rings that the OGC interpretation rejects. Data exported from ArcGIS frequently contains them by design. Choose the interpretation once, record it next to the defect counts, and resist switching mid-programme.

Reason strings are not a stable API. Wording has changed between GEOS releases. Grouping on the prefix before the coordinate bracket, as in Step 2, survives those changes; matching the full string does not.

ST_MakeValid can return a collection. Repairing a polygon may produce a GEOMETRYCOLLECTION containing polygons and stray linework. ST_CollectionExtract(..., 3) keeps only the polygonal parts, which is almost always what a polygon column requires.

The location point can be inside a hole or outside the feature. It marks where GEOS detected the problem, not where the feature is. Do not use it as a proxy for the feature’s position when mapping.

Validity checks do not use the spatial index. The screen in Step 1 is a full scan, and on a large table it is worth running it as part of a scheduled job rather than interactively during an investigation.

When to Escalate

  • A validity rate above a few percent is a source problem. Find the import, the overlay or the export that produced them, because repairing symptoms while the source keeps producing them is unbounded work.
  • Defects clustered in one area or one map sheet point at a specific digitising session or supplier batch — escalate with the spatial extent, which is far more actionable than a feature list.
  • Interior is disconnected or Hole lies outside shell in a cadastral layer should reach the registrar before any repair, because the repair changes which land is inside the parcel.
  • Repairs failing the area-loss guard are exactly the cases a human should see. Route them to the steward queue rather than loosening the guard, which is the usual and wrong response.

Frequently Asked Questions

Why not call ST_IsValidDetail on every row?

Because it does substantially more work than the boolean check — it locates the defect rather than stopping at the first failure. On a large table the difference is significant, and the detail is only meaningful for rows that are actually invalid. Screen with ST_IsValid, expand only the failures.

What is the location returned by ST_IsValidDetail?

A point marking where GEOS detected the problem — the crossing point of a self-intersection, a vertex of a degenerate ring, a point on a hole that escapes its shell. It is a diagnostic marker rather than a complete description: a geometry with three self-intersections reports one location, and re-running after a partial fix reveals the next.

Does the ESRI validity flag change the result?

Yes. Passing flag 1 enables the ESRI-compatible interpretation, which accepts self-touching rings that the OGC rules reject. Data originating in ArcGIS often contains those, so a layer reporting thousands of failures under the default rules may report almost none under flag 1. Choose one interpretation for the whole programme and record it, because switching changes your defect counts overnight.

Can the reason string be parsed reliably?

The leading phrase is stable enough to group on — "Self-intersection", "Ring Self-intersection", "Hole lies outside shell", "Interior is disconnected", "Too few points in geometry component". The coordinates appended to some messages are not worth parsing, because the location column already gives you a geometry. Match on a prefix, never on the whole string.


Related

Back to Geometry Validity Checks for Vector Data