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.

The ground distance corresponding to 0.2 millimetres on the page at five map scales. 0.2 mm on the page, in ground metres1:10,0002 m1:50,00010 m1:250,00050 m1:1,000,000200 m1:10,000,0002,000 m02200 mA vertex closer to its neighbour than this cannot be drawn as a separate point spatialworkflow.io
This is the tolerance to simplify to β€” the detail above it is paid for and invisible.

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.

Vertex count and file size for six simplification tolerances on 51 US states. 51 US states, 53,352 verticesverticesfile sizenone53,352456 kB100 m42,349363 kB200 mβœ“34,484βœ“298 kB500 m22,590198 kB1,000 m14,943133 kB5,000 m!5,140!47 kBAt 1:1,000,000 the 200 m row is visually identical to the top one spatialworkflow.io
File size tracks vertex count almost exactly: 3.6Γ— fewer vertices, 3.4Γ— fewer bytes.

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.

Simplification belongs in the plotting pipeline, not in the stored data. where simplification belongsfull-detail sourcenever overwrittenmeasure from thisarea, length, topologysimplify for thescalein the plot functiondrawdisplay onlySimplified geometry has different areas, lengths and adjacency β€” measure before, not after spatialworkflow.io
Two consumers, one source: the measurements never see the approximation.

Topology, validity and vanishing islands

  • preserve_topology=True is 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.

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.