Simplifying Geometries Before Compression

Precision reduction changes how a coordinate is written; simplification changes how many coordinates there are. They compress by different mechanisms, they fail in different ways, and confusing them produces either a file that is still enormous or a boundary layer that no longer matches its neighbours. This page sets out when simplification is the right lever, and how to apply it without opening slivers along every shared edge. It continues geometry encoding and coordinate precision.

Quick Reference

Situation Lever Why Primary Use Case
16 significant digits, 2 m accuracy Precision reduction Shape unchanged, bounded error Analytical masters
1 m vertex spacing, 1:50,000 output Simplification Vertices exceed what the output can show Cartographic derivatives
Both of the above Simplify, then reduce Fewer vertices, then fewer digits each Tile and preview builds
Shared administrative boundaries Topology-preserving simplification Per-polygon simplification opens slivers Any polygon coverage
Legal or survey boundaries Neither Shape is the deliverable Cadastral, statutory datasets

Two Different Mechanisms

Consider a coastline polygon with 240,000 vertices, each a pair of float64 ordinates: about 3.8 MB of coordinate payload. Precision reduction to a 10 cm grid makes the low mantissa bits constant, and after compression the payload falls to roughly 1.6 MB. The polygon still has 240,000 vertices and still describes exactly the same coast to within 10 cm.

Simplification with a 5 m tolerance removes vertices whose absence displaces the line by less than 5 m — typically 80–90% of them on data digitised at metre spacing. The payload falls to roughly 500 KB before compression, and the polygon now has about 30,000 vertices describing a coast that deviates from the original by up to 5 m.

The levers compose: simplify to 30,000 vertices, then reduce precision on those, and the compressed payload lands near 200 KB. But note what changed. Precision reduction preserved the shape; simplification changed it. That is why simplification belongs on derived copies and not on the analytical master.

What each lever does to the same line The same coastline segment drawn three times. At full precision it has many closely spaced vertices tracing every inlet. After precision reduction the vertices are in almost identical positions and the line looks the same, because only the low-order digits changed. After simplification most vertices are gone and the line is visibly straighter through the small inlets while still following the overall shape. Vertex counts and compressed payload sizes are given beneath each. Full precision 240,000 vertices · 3.8 MB raw 1.6 MB after ZSTD Precision reduced to 10 cm 240,000 vertices · 3.8 MB raw 0.7 MB after ZSTD — shape unchanged Simplified at 5 m 30,000 vertices · 0.5 MB raw 0.2 MB after ZSTD — shape changed The middle panel is safe on any dataset. The right panel is a cartographic decision that needs a reason. Apply both, in that order, on derived copies: simplify first, then reduce precision on what remains.

Topology Is the Hard Part

Simplifying a single polygon is a solved problem. Simplifying a coverage — a set of polygons that tile a region without gaps or overlaps — is not, if you do it one polygon at a time.

The reason is that a shared boundary appears twice, once in each neighbour, and a per-geometry algorithm sees different context each time: the vertices before and after the shared run differ between the two polygons, so the algorithm retains different subsets of it. The two copies of what was one edge now differ, and where they differ there is either a gap or an overlap.

Topology-preserving simplification avoids this by changing the unit of work. It decomposes the coverage into arcs — maximal runs of boundary shared by the same pair of polygons — simplifies each arc exactly once, and rebuilds every polygon from the simplified arcs. Because each arc has one simplified form, both neighbours get the same edge and the coverage stays closed.

Implementation

python
# Requires: geopandas>=1.0, shapely>=2.0, topojson>=1.7  (Python 3.10+)
from __future__ import annotations

from dataclasses import dataclass

import geopandas as gpd
import numpy as np
import topojson as tp
from shapely import get_coordinates


@dataclass(frozen=True)
class SimplifyReport:
    tolerance_m: float
    vertices_before: int
    vertices_after: int
    area_change_pct: float
    invalid_after: int

    @property
    def vertex_reduction_pct(self) -> float:
        return 100 * (1 - self.vertices_after / max(self.vertices_before, 1))


def _vertex_count(frame: gpd.GeoDataFrame) -> int:
    return int(sum(len(get_coordinates(g)) for g in frame.geometry if g is not None))


def simplify_coverage(
    frame: gpd.GeoDataFrame,
    tolerance_m: float,
    *,
    max_area_change_pct: float = 0.5,
) -> tuple[gpd.GeoDataFrame, SimplifyReport]:
    """Simplify a polygon coverage without opening slivers along shared edges.

    Each shared arc is simplified once and both neighbours are rebuilt from it,
    so adjacent polygons cannot diverge.
    """
    if frame.crs is None:
        raise ValueError("a CRS is required — tolerance is in CRS units")
    if frame.crs.is_geographic:
        raise ValueError(
            "simplify in a projected CRS; a tolerance in degrees is not a distance"
        )

    before_vertices = _vertex_count(frame)
    before_area = float(frame.geometry.area.sum())

    topology = tp.Topology(frame, prequantize=False, shared_coords=True)
    simplified = topology.toposimplify(tolerance_m).to_gdf()
    simplified = simplified.set_crs(frame.crs, allow_override=True)

    invalid_after = int((~simplified.geometry.is_valid).sum())
    if invalid_after:
        raise RuntimeError(
            f"tolerance {tolerance_m} produced {invalid_after} invalid geometries"
        )

    after_area = float(simplified.geometry.area.sum())
    area_change = 100 * abs(after_area - before_area) / max(before_area, 1e-9)
    if area_change > max_area_change_pct:
        raise RuntimeError(
            f"total area moved {area_change:.2f}% at tolerance {tolerance_m} — "
            f"above the {max_area_change_pct}% budget"
        )

    return simplified, SimplifyReport(
        tolerance_m=tolerance_m,
        vertices_before=before_vertices,
        vertices_after=_vertex_count(simplified),
        area_change_pct=area_change,
        invalid_after=invalid_after,
    )

Validation

Three assertions catch the three ways simplification goes wrong: broken geometry, drifted area, and — the one people forget — gaps between neighbours.

python
# Requires: geopandas>=1.0, shapely>=2.0 — prove the coverage is still a coverage
import geopandas as gpd
from shapely.ops import unary_union

simplified = gpd.read_parquet("boundaries.simplified.parquet")

assert simplified.geometry.is_valid.all(), "invalid geometry after simplification"

# A coverage's dissolved outline should have no interior rings; any hole is a
# sliver opened between two polygons that used to share an edge.
dissolved = unary_union(simplified.geometry.to_list())
holes = sum(len(part.interiors) for part in getattr(dissolved, "geoms", [dissolved]))
assert holes == 0, f"{holes} sliver(s) opened between neighbouring polygons"

print(f"{len(simplified)} features, coverage intact")

Expected ranges at a 5 m tolerance on a national boundary coverage: 70–90% vertex reduction, total area change under 0.2%, zero invalid geometries, and zero interior rings in the dissolved outline.

Why a coverage must be simplified arc by arc, not polygon by polygon On the left, three polygons sharing boundaries are each simplified independently: the shared edges are reduced to different vertex subsets, so thin gaps and overlaps appear along every boundary. On the right, the same coverage is decomposed into arcs — the runs of boundary shared by a pair of polygons — each arc is simplified once, and both neighbours are rebuilt from the identical simplified arc, so the coverage remains closed with no gaps at all. Per-polygon — each edge simplified twice, differently shaded regions are slivers — gaps that did not exist before every downstream overlay and area calculation inherits them Arc-based — each shared edge simplified once no shading — both neighbours were rebuilt from the same arc the dissolved outline has zero interior rings, which is the test to run Where a defensible tolerance comes from A ladder relating output map scale to the ground distance that a third of a millimetre on the page represents, which is the classic cartographic limit of what a reader can distinguish. At one to five thousand that is one and a half metres; at one to fifty thousand it is fifteen metres; at one to two hundred and fifty thousand it is seventy-five metres. Choosing the tolerance from this ladder yields a number that can be defended; choosing it from a file size target does not. Tolerance from output scale, not from a size target output scale 0.3 mm on the page represents defensible tolerance 1 : 5,000 1.5 m 1 m 1 : 25,000 7.5 m 5 m 1 : 50,000 15 m 10 m 1 : 250,000 75 m 50 m A tolerance derived this way survives the question "why that number?"

Edge Cases and Caveats

Simplifying in geographic coordinates. A tolerance expressed in degrees is not a distance: 0.0001° is about 11 m of latitude everywhere and anywhere from 11 m to almost nothing of longitude depending on where you are. The implementation refuses a geographic CRS for exactly this reason. Project first, simplify, and project back if you must.

Tolerance chosen from a size target. Working backwards from “we need this under 500 MB” produces a tolerance nobody can defend and a dataset whose accuracy claim is now false. Choose the tolerance from what the output can perceive — roughly 0.3 mm at map scale is the classic cartographic rule — and if the result is still too large, reach for precision reduction or a coarser zoom range instead.

Simplifying the analytical master. Once vertices are gone, every area, length, and intersection computed from the layer is subtly different, and downstream consumers will not know. Keep the master intact and simplify only named derivatives — the same discipline that governs precision reduction, applied more strictly because the loss is larger.

Frequently Asked Questions

When should I simplify geometry rather than reduce coordinate precision?

Reduce precision when the problem is digits — sixteen significant figures describing two-metre accuracy — because it costs bounded error and no shape change. Simplify when the problem is vertex count: a coastline digitised at one-metre spacing carries far more vertices than any map at 1:50,000 can render, and no amount of rounding removes them. The two are independent and often both apply, in which case simplify first and then reduce precision on the result.

Why do adjacent polygons develop gaps after simplification?

Because a per-geometry algorithm simplifies each polygon in isolation, and a shared boundary considered as part of polygon A has different neighbouring vertices than the same boundary considered as part of polygon B. The two therefore keep different subsets of vertices and the edges diverge, opening slivers. The fix is topology-preserving simplification, which extracts the shared edges first, simplifies each edge once, and rebuilds both polygons from the simplified edges.

Does simplification help compression as much as precision reduction?

It helps differently and often more, because it removes whole coordinates rather than making existing ones more compressible. Dropping seventy per cent of vertices removes seventy per cent of the coordinate payload before any compression runs. But it changes shape, which precision reduction does not, so it is the more consequential lever and belongs on derived cartographic copies rather than on analytical masters.


← Back to Geometry Encoding and Coordinate Precision