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
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:
toleranceis the largest distance, in CRS units, a simplified line may stray from the originalpreserve_topology=Truekeeps 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_simplifydoes not
Check the CRS, simplify, compare with the neighbours
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 unitspreserve_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
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:
- read the data
- reproject to a projected CRS
- simplify
- 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.
Internal links
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.
- M values are lost when you export the route — simplify also breaks a parallel measures column
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.