How to Simplify Geometry in Python with GeoPandas and Shapely

On this page

Problem statement

Vector datasets often contain far more vertices than you need for a given task. This is common with administrative boundaries, coastlines, parcel outlines, and road centerlines. Extra detail causes practical GIS problems:

  • larger shapefiles, GeoJSON, or GeoPackage files
  • slower plotting and web map rendering
  • slower spatial processing
  • unnecessary detail at small map scales

Geometry simplification reduces vertex count while keeping the overall shape usable. This page simplifies real data — Rutland's 15 ward boundaries at full resolution from the ONS, and the county's roads and buildings from OpenStreetMap — and measures what each setting costs: vertices, file size, overlaps between neighbours, and features that vanish.

Quick answer

Simplifying a geometry keeps its overall shape while reducing the number of vertices. Original — 16 vertices Simplified — 6 vertices spatialworkflow.io
Simplifying keeps the overall shape while cutting the number of vertices.

To simplify geometry in GeoPandas, read your data in a projected CRS, simplify the geometry column, and save the result:

wards = gpd.read_file("data/wards.geojson")                      # British National Grid, metres
simple = wards.copy()
simple["geometry"] = wards.geometry.simplify(tolerance=50, preserve_topology=True)
simple.to_file("output/wards_simplified.gpkg")

print(f"vertices: {vertices(wards):,} -> {vertices(simple):,}")
print(f"GeoJSON size: {geojson_kb(wards):,.0f} kB -> {geojson_kb(simple):,.0f} kB")
vertices: 26,086 -> 1,208
GeoJSON size: 1,117 kB -> 54 kB

Key points:

  • tolerance is the largest distance, in CRS units, a simplified line may stray from the original
  • preserve_topology=True keeps each geometry valid and never collapses a polygon to nothing
  • use a projected CRS in metres, not EPSG:4326, so the tolerance means a distance
  • simplifying each feature separately breaks shared boundaries — shapely.coverage_simplify does not

Check the CRS, simplify, compare with the neighbours

The same coastline at three simplification tolerances. tolerance is a trade, not a settingOriginal4 200 verticestolerance = 20 mshape preserved640 verticestolerance = 200 mdetail destroyed9 verticesTolerance is in CRS units — 0.001 in degrees is roughly 100 m, not a millimetre spatialworkflow.io
The middle panel is what you want: far fewer vertices, the same recognisable shape.

Load vector data into a GeoDataFrame

Start by reading your input and checking geometry types and CRS:

print(wards.geom_type.value_counts().to_dict(), wards.crs.to_epsg(), f"{vertices(wards):,} vertices")
print("the largest ward alone:", wards.assign(v=shapely.get_num_coordinates(wards.geometry.values))
      .sort_values("v").iloc[-1][["WD24NM", "v"]].to_dict())
{'Polygon': 15} 27700 26,086 vertices
the largest ward alone: {'WD24NM': 'Braunston & Martinsthorpe', 'v': 5009}

Checking geometry types matters before simplification: this page covers polygons and lines. If your dataset mixes types, inspect it before applying one workflow to everything.

Check the coordinate reference system before simplifying

Simplification tolerance uses the units of the current CRS. In EPSG:4326 the units are degrees, and a degree of longitude is a different distance at every latitude. In Web Mercator (EPSG:3857) the units are called metres but are stretched by 1/cos(latitude):

lat = np.radians(wards.to_crs(4326).union_all().centroid.y)
print(f"latitude {np.degrees(lat):.1f}°: a 100 'm' tolerance in EPSG:3857 is {100 * np.cos(lat):.0f} m on the ground")
print(f"one degree of longitude here is {111_320 * np.cos(lat) / 1000:.1f} km, one of latitude about 111 km")
latitude 52.7°: a 100 'm' tolerance in EPSG:3857 is 61 m on the ground
one degree of longitude here is 67.5 km, one of latitude about 111 km

Use a local projected CRS — British National Grid for Rutland — so the tolerance you type is the distance you get.

Simplify geometries with GeoPandas

GeoPandas provides .simplify() on the geometry column, using Shapely (and GEOS) underneath:

for tolerance in (10, 50, 100, 250):
    s = wards.geometry.simplify(tolerance)
    print(f"tolerance {tolerance:>3} m: {vertices(s):>6,} vertices ({vertices(s) / vertices(wards):5.1%}), "
          f"all valid: {bool(s.is_valid.all())}")
tolerance  10 m:  3,932 vertices (15.1%), all valid: True
tolerance  50 m:  1,208 vertices ( 4.6%), all valid: True
tolerance 100 m:    710 vertices ( 2.7%), all valid: True
tolerance 250 m:    305 vertices ( 1.2%), all valid: True

Important parameters:

  • tolerance: maximum allowed deviation in CRS units
  • preserve_topology=True (the default): keeps each output geometry valid

Compare original and simplified geometry — including the neighbours

The number of rows stays the same. What changes is how the wards fit together:

def fit(geoms):
    """Overlap between neighbours, and gaps: holes that open up inside the merged county."""
    geoms = gpd.GeoSeries(geoms, crs=wards.crs)
    merged = geoms.union_all()
    overlap = max(geoms.area.sum() - merged.area, 0)
    gap = sum(shapely.Polygon(ring).area for part in shapely.get_parts(merged) for ring in part.interiors)
    return overlap, gap

for tolerance in (10, 50, 100):
    per_feature = fit(wards.geometry.simplify(tolerance).values)
    coverage = fit(shapely.coverage_simplify(wards.geometry.values, tolerance))
    print(f"{tolerance:>3} m  simplify(): overlaps {per_feature[0]:>9,.0f} m², gaps {per_feature[1]:>9,.0f} m² | "
          f"coverage_simplify(): overlaps {coverage[0]:,.0f} m², gaps {coverage[1]:,.0f} m²")
 10 m  simplify(): overlaps    39,512 m², gaps    32,835 m² | coverage_simplify(): overlaps 0 m², gaps 0 m²
 50 m  simplify(): overlaps   296,215 m², gaps   215,458 m² | coverage_simplify(): overlaps 0 m², gaps 0 m²
100 m  simplify(): overlaps   462,331 m², gaps   867,510 m² | coverage_simplify(): overlaps 0 m², gaps 0 m²

Per-feature simplify() moves each ward's copy of a shared boundary independently, so neighbours overlap in some places and leave slivers of no-ward-at-all between them in others — 39,512 m² of overlap and 32,835 m² of gaps at just 10 m. shapely.coverage_simplify() (Shapely 2.1 with GEOS 3.12 or later) simplifies each shared edge once, so the wards still meet exactly. It keeps more vertices for the same tolerance, because a shared edge cannot be simplified differently for each side.

Save the simplified output

Write the result to a new file instead of overwriting the original:

coverage = wards.copy()
coverage["geometry"] = shapely.coverage_simplify(wards.geometry.values, 50)
coverage.to_file("output/wards_coverage_50m.gpkg")
print("coverage still valid:", bool(shapely.coverage_is_valid(coverage.geometry.values)),
      f"| {vertices(coverage):,} vertices | {geojson_kb(coverage):,.0f} kB as GeoJSON")
coverage still valid: True | 2,083 vertices | 91 kB as GeoJSON

Rutland roads, small polygons and single shapes simplified

The data used on this page

Rutland's wards from the ONS December 2024 full-resolution boundaries (OGL), and its roads and buildings from Geofabrik's OpenStreetMap extract of 1 January 2026 (© OpenStreetMap contributors, ODbL). vertices() counts coordinates and geojson_kb() measures a GeoJSON copy, so each change can be read as a number.

from pathlib import Path

import geopandas as gpd
import numpy as np
import requests
import shapely

Path("data").mkdir(exist_ok=True)
Path("output").mkdir(exist_ok=True)
if not Path("data/rutland.shp.zip").exists():
    Path("data/rutland.shp.zip").write_bytes(requests.get(
        "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
if not Path("data/wards.geojson").exists():
    Path("data/wards.geojson").write_bytes(requests.get(
        "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
        "Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
        params={"where": "LAD24CD='E06000017'", "outFields": "WD24NM", "outSR": 27700, "f": "geojson"},
        timeout=120).content)


def vertices(geoms):
    values = geoms.geometry.values if hasattr(geoms, "geometry") else np.asarray(geoms)
    return int(shapely.get_num_coordinates(values).sum())


def geojson_kb(frame):
    frame.to_crs(4326).to_file("output/_size.geojson", driver="GeoJSON")
    return Path("output/_size.geojson").stat().st_size / 1024

print("Shapely", shapely.__version__, "| GEOS", shapely.geos_version_string)
Shapely 2.1.2 | GEOS 3.13.1

Example 1: Simplify road lines for faster display

roads = gpd.read_file("/vsizip/data/rutland.shp.zip/gis_osm_roads_free_1.shp").to_crs(27700)
for tolerance in (2, 10, 20):
    simple = roads.geometry.simplify(tolerance)
    shorter = (roads.length - simple.length).sum() / roads.length.sum()
    print(f"tolerance {tolerance:>2} m: {vertices(roads):,} -> {vertices(simple):,} vertices, "
          f"total length {shorter:.2%} shorter")
tolerance  2 m: 56,865 -> 34,027 vertices, total length 0.09% shorter
tolerance 10 m: 56,865 -> 21,700 vertices, total length 0.82% shorter
tolerance 20 m: 56,865 -> 18,683 vertices, total length 1.66% shorter

Road centrelines lose vertices fast and length slowly: each removed bend shortens the line a little, so distance measurements should always use the original.

Example 2: What happens to small polygons

buildings = gpd.read_file("/vsizip/data/rutland.shp.zip/gis_osm_buildings_a_free_1.shp").to_crs(27700)
for tolerance in (5, 20):
    for preserve in (True, False):
        s = buildings.geometry.simplify(tolerance, preserve_topology=preserve)
        print(f"tolerance {tolerance:>2} m, preserve_topology={preserve!s:5}: "
              f"{int(s.is_empty.sum()):>5} of {len(buildings):,} buildings emptied, {int((~s.is_valid).sum())} invalid")
tolerance  5 m, preserve_topology=True :     0 of 5,513 buildings emptied, 0 invalid
tolerance  5 m, preserve_topology=False:  1112 of 5,513 buildings emptied, 0 invalid
tolerance 20 m, preserve_topology=True :     0 of 5,513 buildings emptied, 0 invalid
tolerance 20 m, preserve_topology=False:  5159 of 5,513 buildings emptied, 0 invalid

With preserve_topology=True no building disappeared even at 20 m — a house smaller than the tolerance keeps enough vertices to stay a valid polygon, however crude. With preserve_topology=False (plain Douglas-Peucker), almost every building collapsed to nothing at 20 m. The faster algorithm is only safe when the tolerance is well below the size of the smallest feature you need to keep.

Example 3: Use Shapely directly on a single geometry

from shapely.geometry import LineString

line = LineString([(0, 0), (1, 0.1), (2, -0.1), (3, 0.05), (4, 0), (5, 0)])
print("Original:                    ", list(line.coords))
print("With topology preservation:  ", list(line.simplify(0.5, preserve_topology=True).coords))
print("Without topology preservation:", list(line.simplify(0.5, preserve_topology=False).coords))
Original:                     [(0.0, 0.0), (1.0, 0.1), (2.0, -0.1), (3.0, 0.05), (4.0, 0.0), (5.0, 0.0)]
With topology preservation:   [(0.0, 0.0), (5.0, 0.0)]
Without topology preservation: [(0.0, 0.0), (5.0, 0.0)]

Tolerance and preserve_topology

How tolerance affects the result

Simplifying each polygon independently versus preserving shared boundaries. why adjacent polygons drift apartgdf.simplify()coverage_simplify()what it doeseach geometry alonethe shared edge onceshared borders✕simplified twice✓simplified oncegaps and overlaps✕39,512 m² + 32,835 m² at 10 m✓nonetoolGeoPandasShapely 2.1, GEOS 3.12+Independent simplification is why simplified administrative layers develop slivers spatialworkflow.io
For adjacent polygons that must stay adjacent, plain simplify() is the wrong tool.

Tolerance controls how aggressively vertices are removed: a vertex can go if the simplified line stays within that distance of it. Small tolerances remove noise; large ones change the shape. There is no universal correct value — it depends on map scale, dataset detail and what the output is for — so test a few and look at the result, and at the numbers above.

GeoPandas vs Shapely for simplification

Use GeoPandas when you want to simplify an entire dataset stored in a GeoDataFrame, and Shapely for a single geometry, an array of geometries, or coverage_simplify, which works on a whole set of polygons at once. GeoPandas' simplify calls the same Shapely functions.

What preserve_topology changes

preserve_topology=True uses a topology-preserving variant of Douglas-Peucker that refuses to create self-intersections or collapse a polygon, which is why it kept every building. The trade-off is that it keeps more vertices than the plain algorithm. Neither setting knows about other features: preserve_topology=True keeps each ward valid and still lets two neighbouring wards overlap, which is the job coverage_simplify exists for.

Degree tolerances, broken shared edges and collapse

Simplifying data in EPSG:4326

Avoid simplifying directly in EPSG:4326 unless you have a very specific reason. Tolerance values in degrees are difficult to interpret and inconsistent across locations.

A better workflow is:

  1. read the data
  2. reproject to a projected CRS
  3. simplify
  4. optionally reproject back

Invalid geometries after processing

Simplification can expose existing geometry problems or create issues if tolerance is too aggressive.

Check validity if output looks wrong:

invalid = gdf_simplified[~gdf_simplified.geometry.is_valid]
print(invalid)

If many features are invalid before simplification, fix those first.

Shared polygon boundaries may stop matching

If you simplify polygon features one by one, neighboring boundaries can drift apart. This matters for administrative areas, parcels, and any layer where adjacent polygons are supposed to share the same edge.

If exact shared boundaries matter, use shapely.coverage_simplify(), which simplifies each shared edge once, as the step-by-step section measured.

Very small features may collapse

With preserve_topology=False, small polygons collapse to empty — 5,159 of Rutland's 5,513 buildings at a 20 m tolerance. With the default True they survive, but may lose important shape detail if the tolerance is too large.

Inspect output for:

  • empty geometries
  • missing small islands or enclaves
  • oversimplified road bends

Attribute data stays the same

Simplification changes geometry only. Your attribute columns remain unchanged unless you explicitly modify them.

If you are not sure why CRS units matter here, read projected vs geographic CRS in GeoPandas.

If you need to change CRS before simplifying, see How to Reproject Spatial Data in Python (GeoPandas).

If you are starting from a file workflow, see How to Read a Shapefile in Python with GeoPandas.

If you need to export the result for web use, see How to Export GeoJSON in Python with GeoPandas.

FAQ

How do I choose the right tolerance for geometry simplification in GeoPandas?

Start with a small value in projected units, then test larger values. For meter-based CRS, try values like 5, 20, 100, or 250 depending on the dataset scale. Always compare the result visually.

Should I simplify geometries before or after reprojecting?

Usually after reprojecting. Simplification tolerance depends on CRS units, so a projected CRS in meters is easier to work with than geographic coordinates in degrees.

What does preserve_topology=True do in Shapely and GeoPandas?

It makes simplification safer by trying to avoid invalid geometry and major per-feature topology problems. This is especially important for polygons. It does not guarantee that shared boundaries between separate polygon features will still match exactly.

Can simplification remove small features or make polygons invalid?

Yes. If tolerance is too large, small polygons may collapse and narrow shapes may disappear. Even with topology preservation, you should validate and inspect the output.

How do I simplify adjacent polygons without creating gaps along shared edges?

GeoPandas .simplify() works per feature, so neighbouring boundaries drift apart — Rutland's wards overlapped by 39,512 m² and opened 32,835 m² of gaps at a 10 m tolerance. Use shapely.coverage_simplify(gdf.geometry.values, tolerance) (Shapely 2.1, GEOS 3.12+), which simplifies each shared edge once; the topojson package does the same for older stacks.

Does simplify() reduce the file size of my output?

Usually yes, because fewer vertices means less coordinate data to store. The saving depends on how aggressive the tolerance is and the output format; vertex-heavy GeoJSON often shrinks noticeably, and you can confirm by comparing the vertex counts before and after.

Why did some polygons become empty or invalid after simplifying?

With preserve_topology=False, a tolerance larger than a feature collapses it to nothing, and aggressive simplification can introduce self-intersections; the default preserve_topology=True prevents both. Check with gdf.geometry.is_valid and gdf.geometry.is_empty, lower the tolerance, and repair survivors with make_valid() if needed.

What algorithm does GeoPandas simplify() use?

It uses the Douglas-Peucker algorithm via Shapely. With preserve_topology=True it runs a topology-preserving variant that avoids invalid output per feature; with preserve_topology=False it uses the faster standard version that can produce self-intersections.