Simplifying Geometries Without Breaking Topology
Simplify PyQGIS geometries without shipping broken data: why Douglas-Peucker does not preserve validity, how shared boundaries come apart, choosing a…
TL;DR: simplification is not validity-preserving — a Douglas-Peucker pass can turn a valid polygon into a self-intersecting one and can pull apart boundaries two polygons used to share — so re-check validity afterwards and compare counts and areas before writing anything. This page is part of the geometry operations and validation guide.
Complete Runnable Code
"""Simplify a layer with a validity gate on both sides of the operation."""
from qgis.core import QgsFeature, QgsGeometry, QgsVectorLayer
def simplify_feature(geometry: QgsGeometry, tolerance: float) -> tuple[QgsGeometry, bool]:
"""Simplify one geometry, repairing it if the operation broke validity.
Returns the geometry and a flag saying whether a repair was needed, so the
caller can report how often simplification is damaging the data.
"""
simplified = geometry.simplify(tolerance)
if simplified.isEmpty():
return geometry, False # tolerance swallowed it; keep the original
if simplified.isGeosValid():
return simplified, False
repaired = simplified.makeValid()
return (repaired, True) if repaired.isGeosValid() else (geometry, True)
def simplify_layer(layer: QgsVectorLayer, tolerance: float) -> dict:
"""Simplify every feature in place, reporting what it cost.
The area comparison is the important number: a simplification that changes
total area by more than a fraction of a per cent has changed the data, not
merely its representation.
"""
if not layer.isValid():
raise ValueError("layer %r is not valid" % layer.name())
repairs = 0
area_before = area_after = 0.0
layer.startEditing()
try:
for feature in layer.getFeatures():
original = feature.geometry()
area_before += original.area()
simplified, repaired = simplify_feature(original, tolerance)
area_after += simplified.area()
repairs += int(repaired)
layer.changeGeometry(feature.id(), simplified)
if not layer.commitChanges():
raise RuntimeError("; ".join(layer.commitErrors()))
except Exception:
layer.rollBack()
raise
drift = 0.0 if area_before == 0 else abs(area_after - area_before) / area_before
return {"repairs": repairs, "area_drift": drift}
Architecture Breakdown
Simplification does not preserve validity
QgsGeometry.simplify() runs the Douglas-Peucker algorithm, which removes vertices whose perpendicular distance from the line between their neighbours is below the tolerance. It considers each ring in isolation and knows nothing about whether the result self-intersects.
A concave polygon with a narrow neck is the classic casualty: removing the vertices that defined the neck lets the two sides cross, producing a bowtie that every downstream GEOS operation will either reject or answer wrongly. That is why the validity check after simplification is not optional.
Shared boundaries come apart
The larger problem is between features rather than within them. Two polygons sharing an edge have identical vertex sequences along it, but they are separate geometries and are simplified separately. Because the algorithm considers each in its own context, it can remove a vertex from one and keep it in the other — and the previously coincident boundary now has gaps and overlaps.
On an administrative boundary layer this is immediately visible as slivers between neighbouring units. No per-feature validity check catches it, because both geometries remain perfectly valid.
Where shared boundaries matter, the honest answer is not to simplify features independently at all. Convert to a topological representation — GRASS v.generalize through Processing, or a purpose-built topology tool — which simplifies each shared edge once and rebuilds the polygons from the result.
Choosing a tolerance
Tolerance is in the layer’s coordinate reference system units, which makes a value chosen for a projected layer meaningless on a geographic one. Work in metres, and pick the value from the scale the output is drawn at rather than from the file size you want.
Most of the saving arrives early. A tolerance around a tenth of the smallest feature you need to distinguish is a reasonable starting point, and the vertex count usually drops by an order of magnitude before anything looks wrong.
Verifying That the Result Is Still the Same Data
A simplification pass should report three numbers, and any of them moving unexpectedly is a reason to stop.
Feature count must be identical: a feature that simplified to an empty geometry has been deleted by accident, which is why the example keeps the original when the result is empty. Total area should change by a fraction of a per cent; a larger drift means the tolerance is removing real shape rather than redundant vertices. And the repair count should be low — if a third of features needed repairing, the tolerance is too aggressive for this data whatever the areas say.
Recording all three in the run log makes a later question about a map answerable without rerunning anything.
Doing It as a Processing Algorithm
For a pipeline, native:simplifygeometries does the same work without an edit session and writes a new layer, which keeps the input untouched:
import processing
def simplify_to_new_layer(source: str, output: str, tolerance: float) -> str:
"""Simplify into a new layer, leaving the source unmodified."""
result = processing.run("native:simplifygeometries", {
"INPUT": source,
"METHOD": 0, # 0 = distance (Douglas-Peucker)
"TOLERANCE": tolerance,
"OUTPUT": output,
})
return result["OUTPUT"]
The algorithm has the same topological limitation — it is the same underlying operation — so the validity re-check still belongs downstream of it. What it does give you is a non-destructive step that a modeller or a qgis_process invocation can drive.
Deciding Whether to Simplify at All
Simplification is a lossy transformation applied to data somebody surveyed, and it is worth being sure the problem it solves is real before applying it. Two questions usually settle it.
The first is what is actually slow. A layer that draws slowly because of vertex count benefits; one that draws slowly because of a data-defined symbology expression or an unindexed provider does not, and simplifying it discards detail for no gain. Measuring before simplifying takes minutes and frequently changes the answer.
The second is whether the loss has to be permanent. QGIS can simplify at render time, leaving the stored geometry untouched, and a tiled service can hold generalised copies at each zoom level alongside the full-resolution source. Both give you the speed without the irreversible edit, which matters because the day somebody needs the original vertices is the day after you removed them.
Production Best Practices
- Re-check validity after simplifying. The operation does not preserve it.
- Keep the original when the result is empty, or you will silently delete small features.
- Compare total area before and after, and treat a large drift as a failure.
- Work in a projected CRS, so the tolerance means metres.
- Do not simplify shared boundaries per feature. Use a topological tool where adjacency matters.
- Report the repair count. It is the earliest signal that the tolerance is too aggressive.
Frequently Asked Questions
Why did my polygons develop gaps between them?
Because each was simplified independently and the shared boundary was not treated as shared. Both resulting polygons are valid; they simply no longer touch. Nothing in a per-feature validity check detects this, which is why it usually reaches a map before anyone notices.
The fix is a topology-preserving generalisation, not a smaller tolerance — a smaller tolerance makes the gaps narrower rather than removing them.
Should I simplify for display or for storage?
Prefer display-time simplification where you can. QGIS can simplify on the fly during rendering without altering the data at all, which gives you fast drawing and keeps the full-resolution geometry for analysis. Simplifying the stored data is a decision to discard detail permanently, and it is worth being explicit that that is what it is.
What does the tolerance actually mean?
The maximum perpendicular distance a removed vertex may have been from the line replacing it. A tolerance of 10 metres therefore guarantees the simplified line is within 10 metres of the original everywhere — which is a useful, checkable property, unlike a percentage-of-vertices target.
Can simplification change the feature count?
simplify() can return an empty geometry when the whole feature is smaller than the tolerance, and writing that empty geometry back effectively removes the feature from the map. Keeping the original in that case, as the example does, preserves the count and leaves small features at full resolution — usually the right trade.
Is makeValid() after simplification safe?
It is safe in that the result is valid, but it may not be the shape you expected: a bowtie repairs into a multipolygon of two parts, which changes the geometry type. Check the type after repairing if the output schema is fixed, and decide deliberately whether to keep the largest part or the whole multipart.
Does this apply to lines as well as polygons?
The vertex reduction does, and lines cannot self-intersect into invalidity in the same way. The shared-geometry problem applies just as strongly, though: a road network simplified per feature develops gaps at junctions where two segments used to meet at an identical vertex.
Related
- Geometry Operations and Validation — the parent guide covering GEOS and validity
- Fixing Invalid Geometries in PyQGIS — repairing what simplification breaks
- Layer Styling and Symbology Automation — scale-dependent rendering as an alternative to discarding detail