Geometry Operations and Validation in PyQGIS
Work with QgsGeometry safely — validity checks, makeValid, buffers, spatial predicates like intersects and contains, GEOS internals, and repairing geometries…
Invalid geometries are the quiet cause of most spatial-analysis failures. A single self-intersecting parcel polygon, or a ring that touches itself at one vertex, will silently corrupt a spatial join, return the wrong features from an overlay, or make a buffer collapse to an empty geometry — and because GEOS often answers rather than raising, the corruption propagates downstream without any exception to flag it. When GEOS does throw, you get an opaque TopologyException deep inside a processing.run call with no obvious culprit. This page, part of the PyQGIS Core Architecture & Data Handling guide, covers QgsGeometry validity checks, in-place repair with makeValid, buffering, the spatial predicates (intersects, contains, within, disjoint), the GEOS and DE-9IM internals behind them, and the planar-versus-ellipsoidal distinction that trips up every distance and area measurement. By the end you will have a defensive workflow that validates geometry before it ever reaches a predicate or overlay.
Prerequisites Checklist
Before working through the patterns below, confirm your environment and background:
- QGIS 3.28+ (LTR recommended):
QgsGeometry.makeValid()and the structure-preserving GEOSMakeValidbackend are stable from 3.28 onward. Earlier 3.x releases exposemakeValid()but with less predictable output geometry types. - Python 3.9+: Required for the modern type-hint syntax used in every sample below.
- Core API familiarity: Working knowledge of
QgsGeometry,QgsVectorLayer, andQgsFeature. If feature iteration is unfamiliar, review the vector and raster data access patterns guide first. - A GEOS-backed build: All standard QGIS distributions bundle GEOS; every predicate and repair operation described here delegates to it. Confirm with
Qgis.geosVersion()if you need to check a specific GEOS release. - Test data with known defects: Real municipal or cadastral extracts almost always contain self-intersections and ring self-touches. Digitised or hand-edited polygon layers are ideal for exercising the repair path.
Validation is not optional polishing — it is the precondition that makes every other operation on this page correct. Treat a geometry as untrusted until isGeosValid() has confirmed otherwise.
How QgsGeometry and GEOS Work Together
QgsGeometry is a lightweight, value-semantics wrapper around an underlying geometry engine. For the analytical operations — predicates, buffers, overlays, validity — that engine is GEOS, the same C++ library that powers PostGIS. When you call geometry.intersects(other), PyQGIS marshals both geometries into GEOS structures and asks GEOS to evaluate the relationship. This has two consequences worth internalising: the operation is fast and battle-tested, but it inherits GEOS’s strict assumptions about what a valid geometry is.
Those predicates are all expressed through the DE-9IM (Dimensionally Extended 9-Intersection Model). DE-9IM describes the relationship between two geometries as a 3×3 matrix comparing the interior, boundary, and exterior of one against the other. intersects, contains, within, touches, crosses, overlaps, and disjoint are each just a named pattern over that matrix. The model is only well-defined when each geometry has an unambiguous interior and boundary — which is exactly what OGC validity guarantees. A self-intersecting polygon has no single coherent interior, so the matrix GEOS computes may not correspond to the shape you think you drew, and the predicate answer becomes unreliable.
That is why validity matters far more than it appears to. An invalid geometry does not necessarily look broken on the canvas, but it breaks the mathematical contract the predicates depend on. The diagram below shows the state a geometry must pass through before it is safe to use.
There is also a subtler dimension to geometry work: measurement. QgsGeometry.length(), area(), distance(), and buffer() all operate in the flat, Cartesian units of the geometry’s coordinates — this is planar measurement. If those coordinates are longitude/latitude degrees, the numbers are geometrically meaningless as distances. For real-world metres you either reproject into a projected CRS, or use QgsDistanceArea, which performs ellipsoidal calculation across the curved surface of the Earth. Keeping planar and ellipsoidal measurement straight is a recurring source of subtle bugs, and we return to it in the pitfalls.
Step-by-Step Implementation
Step 1 — Check Validity
Never assume input geometry is valid. isGeosValid() runs the full GEOS validity check and is the single gate that everything else depends on.
from qgis.core import QgsGeometry, QgsVectorLayer, QgsFeature
def is_feature_valid(feature: QgsFeature) -> bool:
"""
Report whether a feature carries a non-null, non-empty, GEOS-valid geometry.
Args:
feature: A QgsFeature to inspect.
Returns:
True only if the feature has a geometry that passes the GEOS OGC
validity rules; False for null, empty, or invalid geometries.
"""
if not feature.hasGeometry():
return False
geom = feature.geometry()
if geom.isEmpty():
return False
return geom.isGeosValid()
isGeosValid() answers a yes/no question. When you need to know why a geometry failed — and where — call validateGeometry(), which returns a list of QgsGeometry.Error objects, each carrying a human-readable message and, usually, the coordinate of the defect.
from qgis.core import QgsGeometry
def describe_geometry_errors(geom: QgsGeometry) -> list[str]:
"""
Return a human-readable description of each validity error in a geometry.
Uses the GEOS validation method, which reports self-intersections,
ring self-touches, and nested-shell problems with their locations.
Args:
geom: The geometry to diagnose.
Returns:
A list of formatted error strings; empty if the geometry is valid.
"""
method = QgsGeometry.ValidatorGeos
return [
f"{error.what()} at {error.where().toString(4)}"
if error.hasWhere()
else error.what()
for error in geom.validateGeometry(method)
]
Step 2 — Repair Invalid Geometry
Once a defect is confirmed, repair it. For a single geometry in memory, makeValid() returns a corrected copy without ever discarding vertices — it resolves self-intersections by splitting the shape, which is why the returned geometry can change type.
from qgis.core import QgsGeometry
def repair_geometry(geom: QgsGeometry) -> QgsGeometry:
"""
Return a GEOS-valid version of a geometry, repairing it if needed.
Already-valid geometries are returned unchanged. Invalid geometries are
passed through makeValid(), which never drops vertices but may promote a
single-part geometry to a multi-part or collection geometry.
Args:
geom: The possibly-invalid source geometry.
Returns:
A geometry that passes isGeosValid(). Callers should re-check the
result type if the layer enforces a single geometry type.
"""
if geom.isGeosValid():
return geom
fixed = geom.makeValid()
if fixed.isEmpty() or not fixed.isGeosValid():
raise ValueError("makeValid() could not repair the geometry")
return fixed
For a whole layer, drive the repair through the Processing framework rather than looping in Python — native:fixgeometries is faster, streams to an output layer, and is the same tool the GUI exposes. Layer-scale repair strategy, including how to preserve attributes and handle type promotion, is covered in the companion deep-dive on fixing invalid geometries in PyQGIS.
import processing
from qgis.core import QgsVectorLayer
def fix_layer_geometries(layer: QgsVectorLayer) -> QgsVectorLayer:
"""
Return a new in-memory layer with all geometries made valid.
Delegates to the native:fixgeometries algorithm, which repairs every
feature and preserves attributes. The input layer is left untouched.
Args:
layer: A valid QgsVectorLayer that may contain invalid geometries.
Returns:
A memory QgsVectorLayer with repaired geometries.
"""
if not layer.isValid():
raise ValueError(f"Layer '{layer.name()}' is invalid and cannot be repaired.")
result = processing.run(
"native:fixgeometries",
{"INPUT": layer, "METHOD": 1, "OUTPUT": "memory:"},
)
return result["OUTPUT"]
Step 3 — Run a Predicate or Overlay Safely
With validity guaranteed, the spatial predicate is trustworthy. The function below composes the pieces: validate the query geometry, validate each candidate, then apply the exact predicate.
from qgis.core import QgsGeometry, QgsVectorLayer, QgsFeature, QgsFeatureRequest
def features_intersecting(
layer: QgsVectorLayer,
query_geom: QgsGeometry,
) -> list[QgsFeature]:
"""
Return features whose geometry intersects query_geom, validating first.
Both the query geometry and each candidate feature geometry are repaired
if invalid before the DE-9IM intersects predicate is evaluated, so the
result is topologically correct rather than opportunistically wrong.
Args:
layer: Source layer, assumed to share query_geom's CRS.
query_geom: The geometry to test candidates against.
Returns:
A list of features that genuinely intersect query_geom.
"""
query_geom = repair_geometry(query_geom)
matches: list[QgsFeature] = []
for feature in layer.getFeatures():
if not feature.hasGeometry():
continue
candidate = repair_geometry(feature.geometry())
if candidate.intersects(query_geom):
matches.append(feature)
return matches
This linear scan is correct but not scalable — beyond a few thousand features you should pre-filter candidates through a spatial index so the predicate only runs on geometries whose envelopes actually overlap. That combination is covered under advanced patterns below and in the spatial indexing and query optimization guide.
Advanced Patterns
Repairing Invalid Geometries at Scale
makeValid() is the right tool for one geometry, but a repair pipeline needs a policy for what to do with the result. Because a repaired self-intersecting polygon can emerge as a MultiPolygon or even a GeometryCollection, a layer that enforces a single geometry type will reject the fixed feature unless you coerce it. A common policy is: run makeValid(), then extract only the parts matching the layer’s declared type with QgsGeometry.coerceToType(), discarding stray points and lines that a collection repair can introduce. The full decision tree — when to prefer the structure method over the linework method, how to keep attributes aligned, and how to log unfixable features — is the subject of the fixing invalid geometries in PyQGIS deep-dive.
from qgis.core import QgsGeometry, QgsWkbTypes
def repair_and_coerce(geom: QgsGeometry, target_type: QgsWkbTypes.Type) -> QgsGeometry | None:
"""
Repair a geometry and coerce it back to a single target WKB type.
Args:
geom: The source geometry.
target_type: The WKB type the destination layer expects.
Returns:
A valid geometry of the target type, or None if repair yields nothing
compatible.
"""
fixed = geom.makeValid() if not geom.isGeosValid() else geom
parts = fixed.coerceToType(target_type)
return parts[0] if parts else None
Buffers and Spatial Predicates
Buffering is the workhorse of proximity analysis — “everything within 50 metres of a road” is a buffer followed by an intersects predicate. QgsGeometry.buffer(distance, segments) returns a new polygon offset by distance in CRS units, with curved sections approximated by segments straight edges per quadrant. The output of a buffer is always a polygon, and feeding it straight into a predicate is the standard idiom. The critical subtlety is that a buffer on an invalid input can collapse to empty, and a buffer distance in the wrong CRS units is silently meaningless — both failure modes are covered in the dedicated guide to buffering and spatial predicates with QgsGeometry.
from qgis.core import QgsGeometry
def within_distance(
source: QgsGeometry,
target: QgsGeometry,
distance: float,
segments: int = 8,
) -> bool:
"""
Return True if target lies within `distance` CRS units of source.
Buffers the source geometry and tests the target against it. Both inputs
must be valid and share a projected CRS whose units match `distance`.
Args:
source: The origin geometry to buffer.
target: The geometry tested for proximity.
distance: Buffer radius in the CRS's linear units (e.g. metres).
segments: Segments per quarter-circle for curve approximation.
Returns:
True if target intersects the buffered source.
"""
zone = source.buffer(distance, segments)
if zone.isEmpty():
raise ValueError("Buffer collapsed to empty — check input validity and distance.")
return zone.intersects(target)
Combining Predicates with a Spatial Index
Running a predicate against every feature in a large layer is and wasteful, because most candidates are nowhere near the query geometry. The scalable pattern is two-phase: use a QgsSpatialIndex to retrieve the small set of features whose bounding boxes overlap the query envelope in time, then run the exact predicate only on that candidate set. Validity still applies — index a layer of broken geometries and the R-tree envelopes themselves can be malformed — so repair upstream of index construction. The end-to-end pattern, including nearest-neighbour queries and hybrid attribute filtering, lives in the spatial indexing and query optimization guide.
from qgis.core import QgsSpatialIndex, QgsVectorLayer, QgsGeometry, QgsFeatureRequest
def indexed_intersections(
index: QgsSpatialIndex,
layer: QgsVectorLayer,
query_geom: QgsGeometry,
) -> list:
"""
Return features exactly intersecting query_geom using an index pre-filter.
Phase 1 retrieves bounding-box candidates from the R-tree; phase 2 applies
the exact intersects predicate to those candidates only.
Args:
index: A QgsSpatialIndex built from `layer`.
layer: The source layer (same CRS as query_geom).
query_geom: The validated geometry to test.
Returns:
Features whose geometry intersects query_geom.
"""
candidate_ids = index.intersects(query_geom.boundingBox())
if not candidate_ids:
return []
request = QgsFeatureRequest().setFilterFids(candidate_ids)
return [
f for f in layer.getFeatures(request)
if f.geometry().intersects(query_geom)
]
Pitfalls and Debugging
-
Predicates on invalid geometry: This is the headline failure. GEOS may raise
TopologyException: found non-noded intersectionfrom inside a predicate or overlay, or — worse — silently return a wrong answer. Root cause: the geometry violates the OGC validity rules the DE-9IM model assumes. Fix: gate every predicate behindisGeosValid()and repair withmakeValid(). When aTopologyExceptionsurfaces duringprocessing.run, runnative:fixgeometrieson the inputs before the failing algorithm and re-run. -
CRS-unit confusion in buffer distances: Calling
buffer(100, 8)on a layer in EPSG:4326 buffers by 100 degrees — a shape spanning the globe — while the same call on an EPSG:27700 layer buffers by 100 metres. Root cause:bufferuses raw coordinate units, not real-world distance. Fix: reproject to a projected CRS in metres before buffering, or measure withQgsDistanceAreaconfigured with an ellipsoid. The mechanics of reprojecting layers and geometries safely are in the coordinate transformations and CRS handling guide. -
Empty versus null geometry: These are distinct states and confusing them causes crashes. A null geometry (
feature.hasGeometry()isFalse) has no geometry object at all; an empty geometry (geom.isEmpty()isTrue) is a valid object with no coordinates — the common product of a buffer that collapsed or an intersection with no overlap. Fix: guard withhasGeometry()first, thenisEmpty(), before calling any method that dereferences coordinates. Never call a predicate on a null geometry. -
Assuming makeValid preserves type: Downstream code that expects a
Polygoncan break whenmakeValid()returns aMultiPolygonorGeometryCollection. Root cause: splitting a self-intersection legitimately produces multiple parts. Fix: inspectfixed.wkbType()and coerce withcoerceToType()when a single type is required, as shown above. -
Floating-point robustness: GEOS operates on double-precision coordinates, and overlays of geometries with near-coincident vertices can produce slivers, spikes, or
TopologyExceptionfrom non-noded intersections. Root cause: limited floating-point precision at shared edges. Fix: snap coordinates to a grid withQgsGeometry.snappedToGrid()before an overlay, or reduce precision with a rounding pass so nearly-equal vertices become exactly equal. Repairing withmakeValid()also re-nodes intersections and clears many of these. -
Comparing distances across CRS:
geometry.distance()returns planar distance in CRS units; mixing results from a metre-based and a degree-based layer produces nonsensical rankings. Fix: standardise on one projected CRS, or compute all distances through a single configuredQgsDistanceAreainstance for ellipsoidal consistency.
Conclusion
Geometry work in PyQGIS is reliable exactly to the degree that its inputs are valid. QgsGeometry delegates predicates, buffers, and overlays to GEOS, and GEOS answers correctly only when each geometry satisfies the OGC validity rules that make the DE-9IM model well-defined. The defensive workflow is always the same: check with isGeosValid(), diagnose with validateGeometry(), repair with makeValid() or native:fixgeometries, re-check, and only then run the predicate or overlay. Layer on a spatial index for scale, keep planar and ellipsoidal measurement straight, and treat buffer distances as CRS-unit values rather than metres. With validation as the entry gate to every operation, your spatial joins, buffers, and overlays produce results you can trust.
Related
- PyQGIS Core Architecture & Data Handling — parent guide covering the full data-handling stack
- Fixing Invalid Geometries in PyQGIS — layer-scale repair strategy and type coercion after makeValid
- Buffering and Spatial Predicates with QgsGeometry — proximity analysis and DE-9IM predicate patterns
- Spatial Indexing and Query Optimization in PyQGIS — accelerating predicate evaluation with an R-tree pre-filter
- Vector and Raster Data Access Patterns in PyQGIS — efficient feature iteration that feeds geometry operations