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

python
"""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}
A simplification pass that cannot ship broken geometry A five-step pipeline: validate the input, simplify at the chosen tolerance, re-check validity, repair anything that broke, and compare feature counts and areas before writing. validate in isGeosValid() simplify chosen tolerance re-check validity again repair makeValid() compare counts and areas gate then on failure before write

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.

Three simplification tools and what each preserves A grid of QgsGeometry.simplify, the native simplify algorithm and a topology-preserving approach, showing what each guarantees about validity and shared boundaries. keeps validity? keeps shared edges? QgsGeometry.simplify() not guaranteed no native:simplifygeometries not guaranteed no topology-aware pipeline checked after yes, by construction

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.

What tolerance costs and saves A bar chart of vertex count remaining after simplifying an administrative boundary layer at tolerances of 0, 1, 10 and 100 metres, with the count falling steeply and then flattening. no simplification 1.84 M vertices 1 m tolerance 720 k 10 m tolerance 118 k 100 m tolerance 21 k — visibly coarse Indicative vertex counts for a national administrative boundary layer. Most of the saving arrives by 10 metres; past that the map starts to look wrong before the file gets much smaller.

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:

python
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.