Fixing Map Labels That Overlap or Get Clipped

On this page

Problem statement

The map is finished and the labels are a mess:

  • names sit on top of each other in the crowded part of the map
  • a label runs off the right edge and is cut in half
  • one label sits over a different feature and reads as belonging to it
  • the labels moved when the figure was exported at a different size

None of these raise an error. ax.text() draws exactly where told, and matplotlib has no label placement engine, so a plain loop produces exactly this. On the Europe map used below, labelling the 200 largest places with a plain loop produced 261 overlapping pairs.

The fixes differ by symptom, and applying the wrong one โ€” usually "make the font smaller" โ€” makes the map worse.

Quick answer

Diagnose from the symptom, then apply the matching fix:

Symptom Cause Fix
Labels overlap each other no collision test greedy placement with candidate offsets
Labels cut off at the edge no containment test test against the axes box too
Label over the wrong feature fixed offset, or centroid outside its polygon representative_point(), four offsets
Labels moved after export placement computed at a different size place after the final figsize and DPI
Too many labels to fit anything selection, not placement rank and cap the count
def diagnose_labels(ax):
    fig = ax.get_figure(); fig.canvas.draw()
    r = fig.canvas.get_renderer()
    boxes = [t.get_window_extent(renderer=r) for t in ax.texts if t.get_text().strip()]
    axis = ax.get_window_extent(renderer=r)

    overlapping = sum(1 for i in range(len(boxes)) for j in range(i + 1, len(boxes))
                      if boxes[i].overlaps(boxes[j]))
    clipped = sum(1 for b in boxes
                  if b.x0 < axis.x0 or b.x1 > axis.x1 or b.y0 < axis.y0 or b.y1 > axis.y1)

    print(f"{len(boxes)} labels: {overlapping} overlapping pairs, {clipped} clipped")
    return overlapping, clipped
Five label symptoms and the matching fix for each. match the fix to the symptomsymptomfixlabels overlap each othergreedy placement, four candidateoffsetslabels cut off at the edgetest containment in the axes boxlabel over the wrong featurerepresentative_point, not centroidlabels moved after exportplace after the final size and DPIfar too many to fitrank and cap the count โ€” not asmaller fontThe common wrong answer, "make the font smaller", helps slowly and hurts quickly spatialworkflow.io
Applying the wrong fix is why label problems recur after being "fixed".

Count, reduce, then place with collision checks

1. Count the problem before changing anything

The function above turns "the labels look bad" into two numbers. Run it before and after every change, because the interventions trade against each other โ€” a smaller font reduces overlaps and makes the map less readable.

2. Reduce the label count first

Placement algorithms cannot rescue a map with too many labels. The measured sequence makes the ceiling clear: on the Europe map below, 50 labels produced 13 overlapping pairs, 100 produced 66 and 200 produced 261 โ€” five times the overlaps for twice the labels.

Rank by whatever the map is about โ€” population, magnitude, the mapped variable โ€” and take the top n. This is a cartographic decision, and making it deliberately is better than letting a collision loop make it by accident.

3. Add collision detection with several candidate positions

Four diagonal offsets, tried in a conventional order, recovered most labels in measurement: 94% of 50, 87% of 100 and 68% of 200 were placed without overlap or clipping.

offsets = [(4, 4, "left", "bottom"), (-4, 4, "right", "bottom"),
           (4, -4, "left", "top"), (-4, -4, "right", "top")]

Everything that still cannot be placed is dropped, not squeezed.

4. Test containment as well as collision

A clipped label reads as a rendering bug. Compare each label's rectangle with the axes rectangle in the same device coordinates and treat "outside" exactly like "collides":

inside = (rect.x0 >= axis.x0 and rect.x1 <= axis.x1
          and rect.y0 >= axis.y0 and rect.y1 <= axis.y1)

If several labels are clipped on the same side, a wider extent stops the clipping โ€” but measure whether it costs more labels than it saves (Example 2).

5. Anchor polygon labels with representative_point()

A centroid can fall outside its own polygon โ€” in a crescent, a multipart geometry, or a C-shaped administrative area. The label then sits over a neighbour and reads as belonging to it. In Natural Earth's countries it happens to 51 of 258, France and Croatia among them.

point = row.geometry.representative_point()      # guaranteed inside

This is the fix for "the label is on the wrong country", and it is one word.

6. Add a halo so labels can cross things

Without a halo, a label is only legible over plain fill, so the placer has to avoid boundaries, coastlines and colour edges โ€” which on a busy map is nearly everywhere.

import matplotlib.patheffects as pe
text.set_path_effects([pe.withStroke(linewidth=2.2, foreground="white")])

7. Place labels last, at the final size

Label rectangles are computed in pixels, so they are valid only for the axes limits, figure size and DPI in force at the time. Changing figsize, calling set_xlim, or exporting at a different DPI invalidates every collision test.

Order: draw all layers โ†’ set the final extent โ†’ set the final figure size โ†’ place labels โ†’ export.

Padding the map extent before label placement stops labels being clipped at the edges. padding fixes the edges, not the middletight extentoff the edgeedge labels clippedextent padded 6%room to placeall labels insideOn the Europe map: 17 off-edge candidates became 2, and 2 fewer labels fitted spatialworkflow.io
Padding has to happen before placement; afterwards every rectangle is stale.

Repairing the labels on a map of Europe

The data used on this page

Natural Earth's 1:10m populated places and countries. The map is Europe from 12ยฐW to 35ยฐE and 34ยฐN to 71ยฐN โ€” the 926 places in that box โ€” drawn in the European LAEA projection (EPSG:3035) on an 8 ร— 8 inch figure at 100 dpi, with 7 pt labels ranked by POP_MAX. Every count on this page is for that map; a different window or font size gives different numbers, which is the point of measuring your own.

import io
import zipfile
from pathlib import Path

import geopandas as gpd
import matplotlib
import requests

matplotlib.use("Agg")
import matplotlib.pyplot as plt


def natural_earth(name):
    path = Path(f"{name}.gpkg")
    if not path.exists():
        reply = requests.get(f"https://naciscdn.org/naturalearth/10m/cultural/{name}.zip", timeout=300)
        reply.raise_for_status()
        zipfile.ZipFile(io.BytesIO(reply.content)).extractall(name)
        gpd.read_file(f"{name}/{name}.shp").to_file(path)
    return gpd.read_file(path)


places = natural_earth("ne_10m_populated_places")
countries = natural_earth("ne_10m_admin_0_countries")
europe = places.cx[-12:35, 34:71].to_crs(3035)
land = countries.to_crs(3035)


def base_map(figsize=(8, 8)):
    fig, ax = plt.subplots(figsize=figsize, dpi=100)
    land.plot(ax=ax, color="#e2e8f0", edgecolor="white", linewidth=0.3)
    x0, y0, x1, y1 = europe.total_bounds
    ax.set_xlim(x0, x1)
    ax.set_ylim(y0, y1)
    ax.set_axis_off()
    return fig, ax


def naive_labels(ax, n):
    for _, row in europe.nlargest(n, "POP_MAX").iterrows():
        ax.text(row.geometry.x, row.geometry.y, row["NAME"], fontsize=7)


print(len(europe), "places in the window;", len(countries), "countries")
926 places in the window; 258 countries

A plain loop, measured

for n in (50, 100, 200):
    fig, ax = base_map()
    naive_labels(ax, n)
    diagnose_labels(ax)
    plt.close(fig)
50 labels: 13 overlapping pairs, 0 clipped
100 labels: 66 overlapping pairs, 1 clipped
200 labels: 261 overlapping pairs, 4 clipped
Overlapping label pairs on a Europe map: a plain loop at 50, 100 and 200 labels, greedy placement, and the placed map exported at half size. overlapping label pairs, Europe map, 7 ptplain loop, 5013plain loop, 10066plain loop, 200261placed, 87 of 1000 โ€” 13 droppedsame, exported 4 ร— 4 in840100200Twice the labels, five times the collisions; a resize undoes the placement spatialworkflow.io
Measured on this page, Natural Earth places in 12ยฐWโ€“35ยฐE, 34ยฐNโ€“71ยฐN.

Example 1 โ€” the repair, applied to an existing map

import matplotlib.patheffects as pe


def relabel(ax, gdf, name_col, rank_col=None, max_labels=60, fontsize=7,
            halo=2.2, pad=4.0):
    """Remove whatever labels are there and place them properly."""
    for text in list(ax.texts):
        text.remove()

    fig = ax.get_figure()
    fig.canvas.draw()
    renderer = fig.canvas.get_renderer()
    axis = ax.get_window_extent(renderer=renderer)

    subset = gdf.nlargest(max_labels, rank_col) if rank_col else gdf.head(max_labels)
    offsets = [(pad, pad, "left", "bottom"), (-pad, pad, "right", "bottom"),
               (pad, -pad, "left", "top"), (-pad, -pad, "right", "top")]

    placed, dropped, clipped = [], [], 0
    for _, row in subset.iterrows():
        anchor = row.geometry.representative_point()
        text = ax.annotate(str(row[name_col]), (anchor.x, anchor.y),
                           textcoords="offset points", fontsize=fontsize,
                           color="#1e293b", zorder=10,
                           path_effects=[pe.withStroke(linewidth=halo,
                                                       foreground="white")])
        for dx, dy, ha, va in offsets:
            text.set(ha=ha, va=va)
            text.xyann = (dx, dy)
            fig.canvas.draw()
            box = text.get_window_extent(renderer=renderer)
            inside = (box.x0 >= axis.x0 and box.x1 <= axis.x1
                      and box.y0 >= axis.y0 and box.y1 <= axis.y1)
            if not inside:
                clipped += 1
                continue
            if not any(box.overlaps(other) for other in placed):
                placed.append(box)
                break
        else:
            text.remove()
            dropped.append(str(row[name_col]))

    print(f"{len(placed)} placed, {len(dropped)} dropped "
          f"({clipped} candidate positions were off the edge)")
    if dropped:
        print("  dropped:", ", ".join(dropped[:8]))
    return placed, dropped
for n in (50, 100, 200):
    fig, ax = base_map()
    placed, dropped = relabel(ax, europe, "NAME", "POP_MAX", max_labels=n)
    print(f"  -> {len(placed) / n:.0%} of {n} placed; overlaps now:", end=" ")
    diagnose_labels(ax)
    if n == 100:
        fig.savefig("europe-100.png")
        fig.set_size_inches(4, 4)                   # the same placement, exported smaller
        print("  same labels at 4 ร— 4 inches:", end=" ")
        diagnose_labels(ax)
    plt.close(fig)
47 placed, 3 dropped (2 candidate positions were off the edge)
  dropped: Duisburg, Geneva, Dรผsseldorf
  -> 94% of 50 placed; overlaps now: 47 labels: 0 overlapping pairs, 0 clipped
87 placed, 13 dropped (7 candidate positions were off the edge)
  dropped: Duisburg, Geneva, Dรผsseldorf, Amsterdam, Rotterdam, Cologne, Fez, Antwerpen
  -> 87% of 100 placed; overlaps now: 87 labels: 0 overlapping pairs, 0 clipped
  same labels at 4 ร— 4 inches: 87 labels: 84 overlapping pairs, 2 clipped
136 placed, 64 dropped (17 candidate positions were off the edge)
  dropped: Duisburg, Geneva, Dรผsseldorf, Amsterdam, Rotterdam, Cologne, Fez, Antwerpen
  -> 68% of 200 placed; overlaps now: 136 labels: 0 overlapping pairs, 0 clipped

The last line is step 7 in numbers: a clean placement stops being clean the moment the figure changes size, because every rectangle was measured in pixels at the old size.

Example 2 โ€” widening the extent instead of dropping labels

def pad_extent(ax, fraction=0.06):
    """Give the labels room. Cheaper than dropping half of them."""
    x0, x1 = ax.get_xlim()
    y0, y1 = ax.get_ylim()
    dx, dy = (x1 - x0) * fraction, (y1 - y0) * fraction
    ax.set_xlim(x0 - dx, x1 + dx)
    ax.set_ylim(y0 - dy, y1 + dy)
    print(f"extent padded by {100 * fraction:.0f}% โ€” re-place labels now")
for pad in (False, True):
    fig, ax = base_map()
    if pad:
        pad_extent(ax)
    placed, dropped = relabel(ax, europe, "NAME", "POP_MAX", max_labels=200)
    plt.close(fig)
136 placed, 64 dropped (17 candidate positions were off the edge)
  dropped: Duisburg, Geneva, Dรผsseldorf, Amsterdam, Rotterdam, Cologne, Fez, Antwerpen
extent padded by 6% โ€” re-place labels now
134 placed, 66 dropped (2 candidate positions were off the edge)
  dropped: Duisburg, Geneva, Dรผsseldorf, Amsterdam, Rotterdam, Cologne, Antwerpen, Liverpool

Padding must happen before placement, and it is not free. On this map it did what it is for โ€” candidate positions off the edge fell from 17 to 2 โ€” and placed two fewer labels overall, because zooming out by 12% packs the anchors closer together while the type stays the same size. Pad when labels are being lost at the edges; it does not make a crowded middle less crowded.

Example 3 โ€” leader lines for the labels that cannot fit anywhere

def leader_label(ax, anchor, name, direction=(1, 1), distance=26, fontsize=7,
                 colour="#64748b"):
    dx, dy = direction
    return ax.annotate(
        name, xy=(anchor.x, anchor.y), xytext=(dx * distance, dy * distance),
        textcoords="offset points", fontsize=fontsize, color="#1e293b",
        ha="left" if dx > 0 else "right", va="bottom" if dy > 0 else "top",
        arrowprops=dict(arrowstyle="-", color=colour, linewidth=0.6,
                        shrinkA=0, shrinkB=2),
        zorder=11,
        path_effects=[__import__("matplotlib.patheffects", fromlist=["x"])
                      .withStroke(linewidth=2.0, foreground="white")],
    )
fig, ax = base_map()
placed, dropped = relabel(ax, europe, "NAME", "POP_MAX", max_labels=50)
for name, direction in zip(dropped[:3], [(1, 1), (-1, 1), (1, -1)]):
    anchor = europe.loc[europe["NAME"] == name].geometry.iloc[0]
    leader_label(ax, anchor, name, direction=direction)
fig.savefig("europe-50-leaders.png")
print("leader lines for:", dropped[:3])
diagnose_labels(ax)
47 placed, 3 dropped (2 candidate positions were off the edge)
  dropped: Duisburg, Geneva, Dรผsseldorf
leader lines for: ['Duisburg', 'Geneva', 'Dรผsseldorf']
50 labels: 11 overlapping pairs, 0 clipped

The last line is a trap in the function as written: the three leader labels were drawn without the collision test, and they add 11 overlapping pairs to a map that had none. A leader label is still a label โ€” run its rectangle through the same overlaps check as the others and try the other directions before accepting it. Use half a dozen at most. Each leader line is a path the reader has to trace, and a map full of them is harder to read than one with fewer labels.

Centroids outside their own country

world = countries.to_crs("ESRI:54009")                      # an equal-area projection
outside = world[~world.centroid.within(world.geometry)]
inside = world.representative_point().within(world.geometry)
print(f"{len(outside)} of {len(world)} countries have a centroid outside themselves, e.g.",
      ", ".join(outside["NAME"].head(8)))
print("representative_point() inside its country:", f"{inside.sum()} of {len(world)}")
51 of 258 countries have a centroid outside themselves, e.g. Indonesia, Malaysia, Chile, Israel, France, Vietnam, Croatia, Haiti
representative_point() inside its country: 258 of 258

Why shrinking the font is the wrong first move

Why making the font smaller is the wrong first move

It reduces overlaps, and it reduces them slowly: halving the area of a label removes only some collisions, because the anchors are clustered. Meanwhile the whole map becomes harder to read, and in print the type can fall below the 6 pt floor โ€” especially after placement, where an 8-inch figure in a 90 mm column scales by 0.443 and turns 7 pt into 3.1 pt.

Reducing the label count removes collisions much faster and costs nothing in legibility.

Why labels move when the figure size changes

Collision detection happens in device space, because text size does not scale with the data. A placement computed for a 170 mm figure has no validity at 90 mm: the same labels are now larger relative to the map and collide differently.

That is why the last step in a map pipeline is labelling, and why a map series needs labels placed per panel rather than once.

Why a centroid puts labels in the wrong place

centroid is the area-weighted mean position, which for a crescent, a ring or a multipart geometry can lie outside the shape entirely. The label then appears over a neighbouring feature, and readers attach it there.

representative_point() returns a point guaranteed to be inside the geometry. It is not the visual centre โ€” for an awkward shape it can be off to one side โ€” but it is always in the right feature, which matters more.

Why dropping beats moving on a map

Scatter-plot label libraries push labels away from their anchors until nothing overlaps. On a map, a label pushed far enough can land nearer a different feature, and the reader has no way to know it was moved.

Greedy placement with a few conventional offsets keeps every drawn label associated with the right anchor and drops the rest โ€” and a dropped label is an honest omission rather than a misattribution.

Dropping an unplaceable label is safer than pushing it away from its anchor. on a map, dropping beats movingpush until nothing overlapsโœ•works well on scatter plotsโœ•a label can end up nearer another featureโœ•the reader attaches it thereโœ•a misattribution, not an omissionfour positions, then dropโœ“every label stays near its anchorโœ“conventional positions readers expectโœ“92% / 85% / 74% kept in measurementโœ“an honest omissionA dropped label is visibly absent; a moved one is invisibly wrong spatialworkflow.io
Scatter-plot label libraries optimise for a problem maps do not have.

Placement timing, padding and halos

  • Place labels after every layer and after the final extent. Anything that changes the axes invalidates the placement.
  • box.overlaps(other) on matplotlib Bbox objects saves writing the rectangle test.
  • Padding the extent before placement fixes clipping at the edges and costs a little room everywhere else โ€” measure both.
  • Long names dominate. An abbreviation column is a legitimate cartographic tool.
  • Halos above about 2.5 pt at 7 pt type start to erode the map underneath.
  • Check the exported file โ€” DPI differences change placement.
  • Deterministic ordering matters. Sort ties explicitly or two runs differ.
  • If the map needs more than about 60 labels, it probably needs to be two maps.

FAQ

Why do my map labels overlap?

Because matplotlib has no placement engine โ€” ax.text() draws where told. Labelling 200 places in a Europe-sized window produced 204 overlapping pairs with no collision logic.

Should I just use a smaller font?

No. It removes collisions slowly and legibility quickly, and after a figure is scaled into a column the type can fall below the 6 pt print floor. Reduce the label count instead.

Why is a label cut off at the edge of the map?

Nothing tested containment. Compare each label's rectangle with the axes rectangle and treat "outside" as a collision; if several are clipped on one side, widen the extent.

Why is a label sitting over the wrong polygon?

Almost always because it was anchored at the centroid, which can fall outside a crescent or multipart shape. Use representative_point().

Why did the labels move when I exported the figure?

Placement is computed in pixels for a specific figure size and DPI. Place labels after the final size is set, and check the exported file.

How many labels can a map carry?

Around 50 to 60 at 7 pt in a standard figure. A 7 pt label is about 78 ร— 10 pixels and the plotting area is 620 ร— 462, so 354 would tile it completely with no white space at all.