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.
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
# 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.
# 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.
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.
Related
- Geometry Encoding and Coordinate Precision — parent guide: the three encoding levers and how they compose
- Coordinate Precision Reduction for Smaller GeoParquet — the lever that does not change shape
- Converting GeoParquet to PMTiles with Tippecanoe — per-zoom simplification inside a tile build
- Handling Null Values in Spatial Schema Mapping — the same validate-before-you-ship posture applied to attributes