Validating Polygon Self-Intersections in QGIS

You have a polygon layer — cadastral parcels, flood-zone boundaries, municipal zones — and downstream tools are returning spatial join mismatches, area calculation errors, or OGC compliance failures. The most common culprit is a self-intersecting ring: a polygon boundary that crosses itself, creating an ambiguous interior that violates the OGC Simple Features (OGC SF) specification. QGIS exposes two complementary paths for detecting and repairing these defects: a GUI-driven Check Validity algorithm backed by the GEOS topology library, and a fully scriptable PyQGIS interface for headless or pipeline-embedded validation. Both paths are covered here, starting from a clean QGIS 3.28+ installation through to re-validation and audit logging. For the broader geometry validity framework this page sits within, see Geometry Validity Checks for Vector Data.

What a Self-Intersection Actually Is

Before running tools, it helps to know exactly what you are looking for. In OGC SF, a valid polygon ring must be a simple closed curve: it may not cross itself. A self-intersection occurs when an exterior or interior ring doubles back and its boundary intersects at a point other than the shared start/end vertex. The GEOS library, which powers QGIS validation, reports these as Self-intersection[x y] errors, where [x y] is the approximate coordinate of the crossing.

The diagram below illustrates the three most common self-intersection patterns in polygon data:

Common polygon self-intersection patterns Three side-by-side diagrams: a bowtie (figure-eight) polygon where the boundary crosses at the centre, a spike-back polygon where an exterior ring folds back on itself to form a needle, and a polygon with a hole whose inner ring crosses the outer ring boundary. Bowtie crossing Boundary crosses at apex Spike-back spike tip Ring folds back on itself Hole Crossing Outer Ring Inner ring exits outer boundary

Each pattern requires a different repair strategy. The steps below walk through how QGIS surfaces these errors and what to do with each.

Prerequisites

  • QGIS 3.28 or later. The makeValid() function was stabilised in QGIS 3.20; the Check Validity algorithm’s GEOS engine option requires 3.16+. Older versions may silently downgrade to the native engine.
  • GEOS 3.9+ (bundled with the QGIS 3.28 installers for Windows, macOS, and major Linux distributions). Confirm under Help > About.
  • A consistent coordinate reference system (CRS). Before running any validity checks, verify all layers share the same CRS. Mixed projections introduce coordinate drift that can falsely trigger self-intersection errors. Apply CRS normalization before proceeding.
  • A source data backup. The Fix Geometries algorithm writes to a new layer by default, but makeValid() in the PyQGIS console edits in place. Always duplicate your layer or export a backup GeoPackage before scripted repairs.
  • Python 3.10+ (optional for PyQGIS path). Available in the bundled QGIS Python environment; no separate install needed.

Step-by-Step Procedure

1. Load and visually inspect the layer

Open your polygon layer in QGIS and perform a quick visual scan at 1:1 scale. Self-intersecting polygons often appear as “bowtie” or “figure-eight” shapes, or show anomalously small reported areas for visually large features. Use the Identify Features tool to click suspicious polygons — a valid polygon’s area should match its apparent extent.

Verification check: In the Layer Properties > Information panel, note the feature count and geometry type. A MultiPolygon type on a layer that should be Polygon is a strong indicator of prior invalid-geometry repairs that produced unexpected multi-part outputs.

2. Run Check Validity (GEOS engine)

Navigate to Vector > Geometry Tools > Check Validity or open it in the Processing Toolbox (search “check validity”). Configure as follows:

  • Input layer: your polygon layer
  • Method: GEOS — this enforces strict OGC SF compliance
  • Output layers: accept the defaults (three output layers: valid output, invalid output, error output)

Run the algorithm. QGIS writes three in-memory layers:

Output layer Contents
Valid output Features that passed all topological checks
Invalid output Features containing at least one violation
Error output Point layer with one point per error, at the crossing coordinate

Verification check: Open the attribute table of the Error output layer. If it is empty, your layer contains no GEOS-detectable self-intersections. If it has rows, proceed to step 3.

3. Inspect the error output layer

The Error output layer contains a message field with the exact GEOS error string, for example:

Self-intersection[614823.42 5823017.91]

The coordinate pair inside the brackets is the approximate location of the crossing in the layer’s CRS units. Use Zoom to Layer on the error output and then switch to the Invalid output layer to identify which feature(s) produced each error. Enable Open Layer Styling Panel and set a bright fill colour on the invalid output layer to make violations immediately visible.

For datasets with many errors, sort the Error output attribute table by message to group error types. Self-intersection errors are distinct from Ring Self-intersection (an interior ring crosses itself) and Holes are nested (an interior ring sits entirely inside another interior ring).

Verification check: Note the fid (feature identifier) values in the error output. You will need these to confirm repair success in step 5.

4. Repair using Fix Geometries (GUI) or makeValid() (PyQGIS)

Option A — Fix Geometries (GUI, recommended for interactive workflows):

Navigate to Vector > Geometry Tools > Fix Geometries. Set the invalid output layer as input and save to a new GeoPackage. This algorithm applies ST_MakeValid-equivalent logic: it resolves self-intersections by splitting or collapsing the crossing section, and it handles ring orientation and closure issues as a side effect.

Option B — PyQGIS scripted repair (recommended for pipelines and batch jobs):

Open the QGIS Python Console (Plugins > Python Console) and run:

from qgis.core import (
    QgsProject, QgsVectorLayer, QgsGeometry, QgsWkbTypes
)
import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

def validate_and_fix_polygons(layer_name: str, auto_fix: bool = False) -> None:
    """Scan a polygon layer for self-intersections and optionally repair them."""
    layers = QgsProject.instance().mapLayersByName(layer_name)
    if not layers:
        logging.error("Layer '%s' not found in current project.", layer_name)
        return

    layer = layers[0]
    if QgsWkbTypes.geometryType(layer.wkbType()) != QgsWkbTypes.PolygonGeometry:
        logging.error("Target layer must contain polygon geometries.")
        return

    invalid_count = 0
    fixed_count = 0
    type_changed = 0

    layer.startEditing()
    for feature in layer.getFeatures():
        geom = feature.geometry()
        if geom.isNull() or geom.isEmpty():
            continue

        if not geom.isGeosValid():
            invalid_count += 1
            logging.warning(
                "Feature %s: %s",
                feature.id(),
                geom.isGeosValidDetailed()  # returns the GEOS error string
            )

            if auto_fix:
                fixed_geom = geom.makeValid()
                if fixed_geom and not fixed_geom.isNull():
                    # makeValid() may promote Polygon → MultiPolygon
                    if fixed_geom.type() == geom.type():
                        feature.setGeometry(fixed_geom)
                        layer.updateFeature(feature)
                        fixed_count += 1
                    else:
                        logging.info(
                            "Feature %s: geometry type changed after repair (%s → %s). "
                            "Skipping automatic commit — review manually.",
                            feature.id(),
                            geom.type(),
                            fixed_geom.type()
                        )
                        type_changed += 1
                else:
                    logging.error("Feature %s: repair failed.", feature.id())

    if auto_fix:
        layer.commitChanges()
        logging.info(
            "Done. Found %d invalid. Repaired %d. Type-changed (skipped): %d.",
            invalid_count, fixed_count, type_changed
        )
    else:
        logging.info(
            "Dry run complete. Found %d invalid features. "
            "Re-run with auto_fix=True to commit repairs.",
            invalid_count
        )

Call with:

# Dry run first — no edits committed
validate_and_fix_polygons("parcels_2024", auto_fix=False)

# Repair pass — commits in place
validate_and_fix_polygons("parcels_2024", auto_fix=True)

Verification check after either option: Re-run Check Validity (GEOS engine) on the repaired layer. The Error output layer should be empty, or contain only errors unrelated to self-intersections (for example, ring orientation warnings, which many downstream tools tolerate).

5. Log results to an audit table

Before closing QGIS, export the Error output layer (pre-repair) as a GeoPackage or CSV alongside the feature count, CRS, and repair timestamp. This is the minimum audit trail required for spatial data governance and compliance workflows:

import datetime

# Minimal audit record — adapt to your logging infrastructure
audit = {
    "layer": "parcels_2024",
    "run_timestamp": datetime.datetime.utcnow().isoformat(),
    "crs": layer.crs().authid(),
    "total_features": layer.featureCount(),
    "invalid_detected": invalid_count,
    "repaired": fixed_count,
    "type_changed_skipped": type_changed,
    "validation_engine": "GEOS",
}
print(audit)

Write this record to a central audit table in your PostGIS database or append it to a JSONL audit log file that your pipeline monitoring system can ingest.

Interpreting Results

The message field in QGIS’s error output layer maps directly to GEOS error strings. The table below covers the strings you will most commonly encounter on polygon layers:

GEOS error string Root cause Recommended fix
Self-intersection[x y] Exterior ring crosses itself Fix Geometries / makeValid()
Ring Self-intersection[x y] An interior (hole) ring crosses itself Fix Geometries / makeValid()
Holes are nested One interior ring sits inside another Dissolve or manually split the feature
Hole lies outside shell Interior ring extends beyond exterior ring Fix Geometries (may drop the hole)
Duplicate Rings Two identical interior rings exist Remove duplicates with a de-duplication script
Too few points in geometry component Ring has fewer than 4 coordinates (degenerate) Delete or manually rebuild the feature

When makeValid() encounters a bowtie polygon, it typically produces a MultiPolygon containing the two lobes as separate parts. If your downstream schema requires Polygon geometry, either explode the multi-parts into separate features (keeping attribute values on each part) or merge them with a convex hull or dissolve if the business logic permits it.

The PyQGIS method geom.isGeosValidDetailed() returns the same string as the GEOS library’s GEOSisValidDetail() function, so error strings produced in QGIS are directly comparable to those from the rule engine described in Building Rule Engines with GeoPandas when you run cross-platform validation checks.

Gotchas and Edge Cases

CRS-induced micro-intersections. Reprojecting a polygon from a geographic CRS (EPSG:4326) to a projected CRS (e.g., a local UTM zone) can introduce sub-millimetre self-intersections through floating-point rounding. These are invisible in the map canvas but will fail GEOS validation. Before concluding a feature is genuinely corrupt, apply QgsGeometry.snappedToGrid(1e-8) to reduce coordinate precision and re-check validity. If the error disappears, the intersection is a rounding artifact rather than a true topological defect.

makeValid() and area loss. When GEOS resolves a self-intersection by collapsing the crossing section, the output polygon’s area will be smaller than the input’s reported area. For cadastral or land-title data, area changes above a jurisdiction-defined threshold (commonly 0.01 m² in high-precision surveys) must be flagged for manual review rather than silently committed.

Multi-part promotion surprises. A repaired Polygon that becomes a MultiPolygon will fail schema validation if the target PostGIS column is typed geometry(Polygon, SRID). Repair scripts must detect type changes and route them to a manual review queue rather than writing them to production. The type-change detection logic in the PyQGIS script above handles this.

Background validity checker lag. The real-time geometry validation overlay (Settings > Options > Digitizing > Geometry Validation) evaluates features as you digitize but does not always catch self-intersections formed by snapping to nearby features. Run a full Check Validity pass after every editing session, not just during digitizing.

GRASS v.clean side effects. Using GRASS’s v.clean instead of QGIS’s Fix Geometries will sometimes snap vertices to a threshold distance and merge slivers — silently altering coordinates beyond what a topology fix requires. Reserve v.clean for topology-cleaning operations (removing dangles, eliminating overlaps between adjacent polygons) rather than single-feature self-intersection repair.

When to Escalate

QGIS’s GUI and PyQGIS console tools are appropriate for layers up to roughly 500,000 features on a modern workstation. Beyond that threshold, or when the following conditions apply, switch to a database-native or distributed approach:

  • Feature count > 500k: Load the data into PostGIS and run ST_IsValid() and ST_MakeValid() with a spatial index. PostGIS can validate millions of polygons in minutes using parallel query execution. See the geometry validity checks workflow for the PostGIS trigger pattern.
  • Recurring self-intersections from the same data source: The root cause is likely upstream — a CAD-to-GIS conversion, a poorly configured dissolve operation, or coordinate rounding in an ETL step. Fixing geometry in QGIS is a downstream patch. Investigate and fix the source process.
  • Cascading errors after spatial overlays: Union, intersection, and clip operations compound topological errors. If Check Validity returns hundreds of new errors after an overlay operation, the issue is the overlay algorithm’s precision model, not the input data. Consider using ST_Snap in PostGIS before the overlay, or switch to a topology-aware overlay library such as shapely.set_precision() + overlay() in batch-processing workflows with GeoPandas and Dask.
  • Compliance audit requires traceable, signed-off repairs: Manual QGIS repairs do not produce a machine-readable audit trail by default. For regulatory submissions, embed the PyQGIS script above into an automated pipeline that writes every repair action to a structured log, and store pre- and post-repair snapshots in versioned storage.

Related:

Back to Geometry Validity Checks for Vector Data