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
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.
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.
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 matplotlibBboxobjects 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.
Internal links
- How to label map features without overlaps in Matplotlib โ the full implementation
- Labelling explained: why automatic map labels collide โ the measurements and the reasoning
- Fixing map text that is too small in the exported file โ before shrinking the font
- How to add map legends and labels โ the simpler cases
- Visual hierarchy explained: what a map reader sees first โ the space budget
- How to build a print-ready map layout in Matplotlib โ placing labels last
- How to make a map series with consistent symbology โ per-panel labelling
- Map scale explained: how much detail a scale can hold โ the same budget for geometry
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.