Fixing Map Labels That Overlap or Get Clipped

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 a Europe-sized window, labelling the 200 largest places produced 204 overlapping pairs with no placement logic at all.

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
Triage table of five label symptoms mapped to their fixes.
Applying the wrong fix is why label problems recur after being "fixed".

Step-by-step solution

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: 50 labels produced 9 overlapping pairs, 100 produced 67 and 200 produced 204.

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: 92% of 50, 85% of 100 and 74% of 200 were placed without overlap.

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, the real fix is a wider extent or a margin, not a different offset.

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.

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.

Two panels showing labels clipped at a tight extent and placed after padding.
Padding has to happen before placement; afterwards every rectangle is stale.

Code examples

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

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")

Padding must happen before placement. A 6% pad on each side adds roughly 12% more area, which in a crowded map is worth several labels.

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")],
    )

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.

Explanation

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.

Two panels contrasting pushing labels apart with placing then dropping them.
Scatter-plot label libraries optimise for a problem maps do not have.

Edge cases or notes

  • 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 often saves more labels than a cleverer algorithm.
  • 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.