Map Scale Explained: How Much Detail a Scale Can Hold
On this page
Problem statement
A coastline digitised at 1:10,000 plotted on a page at 1:2,000,000 contains roughly two hundred times more vertices than the page can show. The extra vertices do not make the map more accurate β they make the file larger, the rendering slower and the line noisier, and every one of them is invisible.
The relationship is not a matter of taste. A person can resolve about 0.2 mm on a printed page, so the ground distance corresponding to one visible dot is a fixed function of scale:
scale 0.2 mm on the ground
1:10,000 2 m
1:50,000 10 m
1:250,000 50 m
1:1,000,000 200 m
1:10,000,000 2,000 m
Any vertex closer to its neighbour than that distance cannot be seen. Removing them is not a loss of information at this scale β it is the definition of drawing at this scale.
Quick answer
Simplify to the tolerance the scale can show, and measure what it costs:
def tolerance_for_scale(scale_denominator, resolvable_mm=0.2):
"""Ground distance of the smallest visible mark, in map units (metres)."""
return resolvable_mm / 1000 * scale_denominator
tol = tolerance_for_scale(1_000_000) # 200 m
simplified = states.copy()
simplified["geometry"] = states.geometry.simplify(tol, preserve_topology=True)
print(f"tolerance {tol:,.0f} m")
tolerance 200 m
Measured on the 51 US state polygons from Natural Earth β 53,352 vertices in an equal-area projection (Example 3):
tolerance vertices % of original PDF size
none 53,352 100.0% 456 kB
50 m 47,217 88.5% 403 kB
100 m 42,349 79.4% 363 kB
200 m 34,484 64.6% 298 kB
500 m 22,590 42.3% 198 kB
1,000 m 14,943 28.0% 133 kB
2,000 m 9,470 17.8% 86 kB
5,000 m 5,140 9.6% 47 kB
At 1:1,000,000 the correct tolerance is 200 m, which removes a third of the vertices and a third of the file size while changing nothing a reader can see.
From scale to tolerance to simplified geometry
1. Know the scale you are actually drawing at
Scale is a property of the finished figure, not of the data. Compute it from the extent and the physical width of the output:
def map_scale(ax):
"""The representative fraction of the plotted figure, from the axes' drawn width."""
fig = ax.figure
fig.canvas.draw() # settles the equal-aspect layout
drawn_m = ax.get_window_extent().width / fig.dpi * 0.0254
x0, x1 = ax.get_xlim()
return (x1 - x0) / drawn_m
for name, layer, width in (("the UK", uk, 6), ("the US states", states, 8)):
fig, ax = plt.subplots(figsize=(width, width * 0.75))
layer.plot(ax=ax)
scale = map_scale(ax)
print(f"{name}, {width} in figure: axes {ax.get_window_extent().width / fig.dpi:.2f} in wide, "
f"1:{scale:,.0f}, one visible mark = {tolerance_for_scale(scale):,.0f} m")
plt.close(fig)
the UK, 6 in figure: axes 1.89 in wide, 1:14,981,585, one visible mark = 2,996 m
the US states, 8 in figure: axes 6.20 in wide, 1:65,448,024, one visible mark = 13,090 m
The width that matters is the axes', not the figure's: an equal-aspect map rarely fills its figure, and the UK, tall and narrow, fills well under half of a 6-inch one. Measured, a 6-inch figure of the UK uses axes 1.89 inches wide and is drawn at about 1:15,000,000, where one visible mark is 3 km of ground β and a dataset digitised to 1 m precision is carrying three orders of magnitude more detail than the page can render.
2. Convert the scale to a tolerance
The 0.2 mm rule is the standard cartographic threshold for what a reader can separate on paper. For screen output, work from the pixel size instead: one CSS pixel at typical viewing distance is comparable, so use the ground distance per pixel.
Both give the same kind of number: a distance below which geometry is invisible.
3. Simplify with topology preserved
simplify(tolerance, preserve_topology=True) uses Douglas-Peucker while refusing to produce invalid geometry. It is fast, it is in Shapely, and for a single layer it is usually enough.
What it does not do is keep shared boundaries consistent between neighbouring polygons. Two polygons simplified independently can develop gaps and overlaps along the boundary they used to share, because each one made its own decisions about which vertices to keep.
4. For coverages, simplify the topology, not the polygons
Where polygons share boundaries β administrative areas, land parcels, any coverage β independent simplification produces slivers. The correct approach is topological simplification, which extracts the shared arcs, simplifies each arc once, and rebuilds the polygons from them.
In Python that is GeoSeries.simplify_coverage (GeoPandas 1.1 with Shapely 2.1): it simplifies each shared edge once. Measured on the 48 conterminous states:
lower48 = states[~states["name"].isin(["Alaska", "Hawaii"])]
print("valid coverage to start with:", shapely.coverage_is_valid(lower48.geometry.values))
for tol in (1_000, 5_000, 20_000):
for method, layer in (("simplify", lower48.geometry.simplify(tol, preserve_topology=True)),
("simplify_coverage", lower48.geometry.simplify_coverage(tol))):
union = shapely.union_all(layer.values)
overlap_km2 = (layer.area.sum() - union.area) / 1e6
gaps = sum(len(part.interiors) for part in getattr(union, "geoms", [union]))
print(f"{tol / 1000:4.0f} km {method:18} overlaps {overlap_km2:8,.0f} kmΒ² gaps {gaps:4}")
valid coverage to start with: True
1 km simplify overlaps 980 kmΒ² gaps 167
1 km simplify_coverage overlaps 0 kmΒ² gaps 0
5 km simplify overlaps 6,627 kmΒ² gaps 111
5 km simplify_coverage overlaps 0 kmΒ² gaps 0
20 km simplify overlaps 48,464 kmΒ² gaps 61
20 km simplify_coverage overlaps -0 kmΒ² gaps 0
A gap here is a hole in the union of the states β somewhere that belongs to no state after simplification. If a coverage tool is not available, the pragmatic fallback is a tolerance small enough that the slivers are sub-pixel β which is exactly the tolerance the scale implies.
5. Check the result at the output size, not zoomed in
Simplification always looks wrong at full zoom, because you are looking at detail the map will never show. Judge it by exporting at the real size and comparing.
The measurement to trust is the file: at 200 m tolerance the PDF fell from 456 kB to 298 kB (Example 3) β and at the scale an 8-inch figure of the states is actually drawn, every tolerance in the ladder is still below one visible mark.
6. Keep the full-detail data
Simplify for display, in the plotting pipeline, and keep the source geometry intact. A simplified layer is a rendering artefact; overwriting the original destroys measurements you will want later β areas, lengths and adjacency all change when vertices are removed.
Simplifying US states and the UK for their scale
The data used on this page
The 51 US states and District of Columbia from Natural Earth at 1:10 million, in Albers equal-area (EPSG:5070), and the United Kingdom from its 1:50 million countries in the British National Grid.
import io
import os
import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import requests
import shapely
SOURCES = {
"states.zip": "https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_1_states_provinces.zip",
"countries.zip": "https://naciscdn.org/naturalearth/50m/cultural/ne_50m_admin_0_countries.zip",
}
for name, url in SOURCES.items():
if not os.path.exists(name):
reply = requests.get(url, headers={"User-Agent": "spatialworkflow.io guide"}, timeout=300)
reply.raise_for_status()
with open(name, "wb") as out:
out.write(reply.content)
states = gpd.read_file("states.zip")
states = states[states["adm0_a3"] == "USA"][["name", "geometry"]].to_crs(5070).reset_index(drop=True)
uk = gpd.read_file("countries.zip").query("ADM0_A3 == 'GBR'").to_crs(27700)
print(f"{len(states)} states, UK extent {uk.total_bounds.round(-3).tolist()}")
51 states, UK extent [2000.0, 18000.0, 655000.0, 1217000.0]
Example 1 β the scale-to-tolerance pipeline
import geopandas as gpd
def simplify_for_scale(gdf, scale_denominator, resolvable_mm=0.2, report=True):
"""Simplify to what the scale can show, and say what it cost."""
tol = resolvable_mm / 1000 * scale_denominator
def count(geoms):
total = 0
for g in geoms:
if g is None or g.is_empty:
continue
if g.geom_type == "Polygon":
total += len(g.exterior.coords)
elif g.geom_type == "MultiPolygon":
total += sum(len(p.exterior.coords) for p in g.geoms)
elif g.geom_type in ("LineString", "MultiLineString"):
total += sum(len(part.coords) for part in getattr(g, "geoms", [g]))
return total
before = count(gdf.geometry)
out = gdf.copy()
out["geometry"] = gdf.geometry.simplify(tol, preserve_topology=True)
after = count(out.geometry)
if report:
print(f"scale 1:{scale_denominator:,} β tolerance {tol:,.0f} m")
print(f"vertices {before:,} β {after:,} ({100 * after / before:.1f}%)")
invalid = (~out.geometry.is_valid).sum()
if invalid:
print(f" ! {invalid} geometries became invalid β lower the tolerance")
return out
for scale in (1_000_000, 10_000_000):
simplify_for_scale(states, scale)
scale 1:1,000,000 β tolerance 200 m
vertices 53,352 β 34,484 (64.6%)
scale 1:10,000,000 β tolerance 2,000 m
vertices 53,352 β 9,470 (17.8%)
Example 2 β how much detail is invisible, before you simplify
import numpy as np
def invisible_vertex_fraction(gdf, scale_denominator, resolvable_mm=0.2):
"""What fraction of vertices are closer together than the page can resolve?"""
tol = resolvable_mm / 1000 * scale_denominator
close = total = 0
for geom in gdf.geometry:
if geom is None or geom.is_empty:
continue
rings = ([geom.exterior] if geom.geom_type == "Polygon"
else [p.exterior for p in geom.geoms] if geom.geom_type == "MultiPolygon"
else [geom])
for ring in rings:
coords = np.asarray(ring.coords)
if len(coords) < 2:
continue
steps = np.linalg.norm(np.diff(coords, axis=0), axis=1)
close += int((steps < tol).sum())
total += len(steps)
print(f"at 1:{scale_denominator:,} ({tol:,.0f} m per visible dot)")
print(f"{close:,} of {total:,} segments ({100 * close / total:.1f}%) are shorter "
f"than one resolvable mark")
return close / total
for scale in (1_000_000, 10_000_000, 66_700_000):
invisible_vertex_fraction(states, scale)
at 1:1,000,000 (200 m per visible dot)
574 of 52,948 segments (1.1%) are shorter than one resolvable mark
at 1:10,000,000 (2,000 m per visible dot)
32,268 of 52,948 segments (60.9%) are shorter than one resolvable mark
at 1:66,700,000 (13,340 m per visible dot)
50,967 of 52,948 segments (96.3%) are shorter than one resolvable mark
This is the number that ends the argument about whether simplification is "losing data" β and it depends entirely on the scale. For the states it was 1.1% of segments at 1:1,000,000, 60.9% at 1:10,000,000, and 96.3% at 1:66,700,000, the scale at which an 8-inch figure of them is actually drawn. Douglas-Peucker still removes a third of the vertices at 1:1,000,000, because its tolerance is a deviation from the simplified line, not a segment length.
Example 3 β a simplification ladder to choose from
def simplification_ladder(gdf, tolerances=(50, 100, 200, 500, 1000, 2000, 5000),
render=None):
"""Vertices and file size at each tolerance, so the choice is informed."""
import io
import matplotlib.pyplot as plt
base = None
print(f"{'tolerance':>10} {'vertices':>10} {'% kept':>8} {'PDF kB':>8}")
for tol in (None, *tolerances):
layer = gdf if tol is None else gdf.assign(
geometry=gdf.geometry.simplify(tol, preserve_topology=True))
n = sum(len(g.exterior.coords) if g.geom_type == "Polygon"
else sum(len(p.exterior.coords) for p in g.geoms)
for g in layer.geometry)
base = base or n
buf = io.BytesIO()
fig, ax = plt.subplots(figsize=(8, 5))
layer.plot(ax=ax, edgecolor="white", linewidth=0.4)
ax.set_axis_off()
fig.savefig(buf, format="pdf", bbox_inches="tight")
plt.close(fig)
label = "none" if tol is None else f"{tol:,} m"
print(f"{label:>10} {n:10,} {100 * n / base:7.1f}% {buf.tell() / 1024:8.1f}")
simplification_ladder(states)
tolerance vertices % kept PDF kB
none 53,352 100.0% 455.5
50 m 47,217 88.5% 403.0
100 m 42,349 79.4% 363.3
200 m 34,484 64.6% 297.5
500 m 22,590 42.3% 197.6
1,000 m 14,943 28.0% 133.0
2,000 m 9,470 17.8% 85.5
5,000 m 5,140 9.6% 46.9
Why 0.2 mm
Why 0.2 mm and not some other number
Two marks closer than about 0.2 mm merge for a reader at normal viewing distance on paper. That threshold has been the working assumption in cartography for a long time and it survives contact with modern output: at 300 dpi, 0.2 mm is about 2.4 pixels, comfortably above the printer's resolution and at the edge of what the eye separates.
It is a rule of thumb, not a constant. Use 0.1 mm for a map that will be examined closely, 0.3 mm for one seen at arm's length β the arithmetic is the same and the conclusion barely changes.
Why detail beyond the threshold costs more than it looks
Every invisible vertex is paid for three times: in file size, in render time, and in the visual noise that a stroked line accumulates when it doubles back on itself within a pixel.
The file measurements make the first concrete: 53,352 vertices produced a 456 kB PDF; 34,484 produced 298 kB with no visible difference at the intended scale. In a report with twenty such figures, that is 3 MB of invisible geometry.
Why coverages need topological simplification
simplify() operates on one geometry at a time. Two polygons that shared a boundary are simplified independently, and each keeps a different subset of the shared vertices β so the boundary they used to share becomes two slightly different lines with slivers between them.
At the tolerance the scale implies, those slivers are sub-pixel and invisible, which is why the simple approach usually survives. It stops being acceptable when the simplified layer is used for anything but drawing: areas change, adjacency breaks, and a point-in-polygon test can fall in a gap.
Why simplification is a display step and not a data step
Simplified geometry has different areas, different lengths and different topology. Anything measured from it is measured from an approximation chosen for a particular output size.
Keeping the simplification inside the plotting function β rather than saving a simplified file and forgetting β means the measurements always come from the full-detail data and the display always comes from geometry appropriate to its scale.
Topology, validity and vanishing islands
preserve_topology=Trueis not free but it is nearly always right; without it, polygons can self-intersect.- Simplify in a projected CRS. A tolerance in degrees is a different distance at every latitude.
- Check validity afterwards β a large tolerance on a thin polygon can collapse it.
- Small islands vanish at high tolerances. Filter by area first and decide deliberately.
- Web maps simplify per zoom level, which is the same rule applied at many scales.
- Vector export benefits most. A raster export at fixed DPI is already resolution-limited.
- Do not simplify the layer you measure from. Area and length change.
- Line data has the same rule β a road network at 1:250,000 needs 50 m tolerance, not 1 m.
Internal links
- How to simplify geometry in GeoPandas β the operation itself
- Zoom and generalisation explained β the same rule applied per web-map zoom level
- Which map elements are actually required β the other space budget
- Fixing a map export that is hundreds of megabytes β when the invisible detail has been shipped
- How to export a map at print quality β where scale meets DPI
- How to reduce GIS file size β the storage side of the same problem
- Coordinate precision explained β precision that exceeds accuracy
- How to fix gaps and overlaps in a polygon coverage β repairing slivers after simplification
FAQ
How much detail can a map at a given scale show?
About 0.2 mm on the page. At 1:1,000,000 that is 200 m on the ground; at 1:50,000 it is 10 m. Vertices closer together than that cannot be drawn.
How do I choose a simplification tolerance?
From the scale: tolerance = 0.0002 Γ scale denominator in metres. At 1:1,000,000 that is 200 m, which removed 35% of vertices from a real dataset with no visible change.
Does simplifying lose accuracy?
It loses detail the output cannot show. Keep the full-detail data for measurement and simplify only in the plotting pipeline β areas and lengths do change.
How much smaller does the file get?
Measured on 51 polygons: 53,352 vertices and 456 kB at full detail; 34,484 vertices and 298 kB at 200 m tolerance; 14,943 and 133 kB at 1,000 m.
Should I simplify before or after reprojecting?
After, and in the projected CRS you will plot in. A tolerance in degrees is a different ground distance at every latitude.