How to Label Map Features Without Overlaps in Matplotlib
On this page
Problem statement
Matplotlib has no label placement engine. ax.text() draws text exactly where you tell it, and a loop over a GeoDataFrame produces a map where names sit on top of each other, on top of their own symbols, and off the edge of the figure.
There is no setting that fixes this, because the problem is not a setting. Placement requires knowing where every other label already is, in pixels, which means rendering the figure and measuring text extents.
The good news is that a competent labeller is about forty lines, and it measurably works: on a Europe-sized window with 752 candidate places, a greedy placer with four candidate positions kept 96% of 50 labels, 85% of 100 and 71% of 200 — against 9, 80 and 248 overlapping pairs with no placement logic at all (Example 4).
Quick answer
Rank, render, measure, place, drop — in that order:
import matplotlib.patheffects as pe
def overlaps(a, b):
return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
def label_points(ax, gdf, name_col, rank_col, max_labels=60, fontsize=7):
fig = ax.get_figure()
fig.canvas.draw() # extents need a rendered figure
renderer = fig.canvas.get_renderer()
placed, dropped = [], []
offsets = [(4, 4, "left", "bottom"), (-4, 4, "right", "bottom"),
(4, -4, "left", "top"), (-4, -4, "right", "top")]
for _, row in gdf.nlargest(max_labels, rank_col).iterrows():
point = row.geometry
text = ax.annotate(str(row[name_col]), (point.x, point.y), xytext=(0, 0),
textcoords="offset points", fontsize=fontsize,
zorder=10,
path_effects=[pe.withStroke(linewidth=2.2,
foreground="white")])
for dx, dy, ha, va in offsets:
text.set(ha=ha, va=va)
text.xyann = (dx, dy)
fig.canvas.draw()
bb = text.get_window_extent(renderer=renderer)
rect = (bb.x0, bb.y0, bb.x1, bb.y1)
if not any(overlaps(rect, other) for other in placed):
placed.append(rect)
break
else:
text.remove()
dropped.append(str(row[name_col]))
print(f"{len(placed)} placed, {len(dropped)} dropped")
return placed, dropped
What you get — from The data used on this page
752 candidate places in the window
Finish the map, rank, render, then place
1. Finish the map before you label it
Label positions are computed in pixels, so they are only valid for the axes limits, figure size and DPI in force when they were placed. Set the extent, draw every layer, then label.
If you later call ax.set_xlim() or change figsize, the labels stay where they were put and the collision guarantees are gone.
2. Rank the candidates
Deciding what to label is a bigger lever than deciding where. Use a ranking column that reflects the map's subject:
candidates = cities.nlargest(60, "POP_MAX") # a map about places
candidates = sites.nlargest(60, "annual_tonnage") # a map about throughput
Placing in ranked order means that when space runs out, the map loses its least important labels rather than whichever rows happened to be last in the file.
3. Render once, then measure with a renderer
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
bb = text.get_window_extent(renderer=renderer)
Every candidate position needs a fresh draw() because the text object has moved. That makes labelling the slowest part of a map — 4.71 s for sixty labels on the Europe map here, because each try redraws every country and all 752 points — which is acceptable for a static figure and worth caching for a map series.
4. Try several positions per label
Four diagonal offsets, in the conventional order: upper-right, upper-left, lower-right, lower-left. Upper-right first matches what readers expect and keeps the map consistent.
Adding eight or sixteen positions gains little and starts placing names in unexpected places, which costs more in readability than it gains in coverage.
5. Add a halo, and put the labels on top
path_effects=[pe.withStroke(linewidth=2.2, foreground="white")]
zorder=10
A halo lets a label cross a boundary, a coastline or a choropleth edge and stay readable. Without one, the only safe placements are over plain fill, which on a real map means almost nowhere.
6. Check containment as well as collision
A label that runs off the axes is clipped, which reads as a bug. Test the label's rectangle against the axes rectangle in the same device coordinates, and treat "outside" the same as "collides".
7. Report the drops
Print how many labels were dropped and which ones. This is how you find out that the three places the map is about were all suppressed because they are close together — at which point the fix is a leader line or a different extent, not more code.
Points, polygons and lines in one labeller
The data used on this page
Natural Earth's 1:10m populated places as cities, ranked by POP_MAX, and its 1:10m countries as a grey backdrop, in a Europe window of 11° W to 30° E, 35 to 60° N. Every figure is 8 × 6 inches at 100 dpi, which, with the map's geographic aspect kept, gives a plotting area of 512 × 462 pixels; new_map() draws the backdrop and the points so that each experiment starts from the same finished map.
import os
import geopandas as gpd
import matplotlib
import matplotlib.pyplot as plt
import requests
matplotlib.use("Agg")
NE = "https://naciscdn.org/naturalearth/10m/cultural/"
for name in ("ne_10m_populated_places.zip", "ne_10m_admin_0_countries.zip"):
if not os.path.exists(name):
open(name, "wb").write(requests.get(NE + name, timeout=300).content)
W, S, E, N = -11, 35, 30, 60
cities = gpd.read_file("ne_10m_populated_places.zip").cx[W:E, S:N][["NAME", "POP_MAX", "geometry"]]
countries = gpd.read_file("ne_10m_admin_0_countries.zip").cx[W:E, S:N]
def new_map():
fig, ax = plt.subplots(figsize=(8, 6), dpi=100)
countries.plot(ax=ax, color="#f1f5f9", edgecolor="#cbd5e1", linewidth=0.4)
cities.plot(ax=ax, markersize=2, color="#1a3a6b")
ax.set_xlim(W, E)
ax.set_ylim(S, N)
ax.set_axis_off()
return fig, ax
print(f"{len(cities):,} candidate places in the window")
752 candidate places in the window
Example 1 — points, polygons and lines in one labeller
import matplotlib.patheffects as pe
def label_layer(ax, gdf, name_col, rank_col=None, max_labels=60, fontsize=7,
halo=2.2, pad=4.0, min_area_fraction=0.004):
"""Points get offset labels; polygons get centred ones if they are big
enough; lines get a label at their midpoint."""
fig = ax.get_figure()
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
axis_box = ax.get_window_extent(renderer=renderer)
subset = gdf.nlargest(max_labels, rank_col) if rank_col else gdf.head(max_labels)
total_area = gdf.geometry.area.sum() or 1.0
placed, dropped = [], []
for _, row in subset.iterrows():
geom = row.geometry
if geom is None or geom.is_empty:
continue
if geom.geom_type in ("Polygon", "MultiPolygon"):
if geom.area / total_area < min_area_fraction:
dropped.append(f"{row[name_col]} (too small)")
continue
anchor, positions = geom.representative_point(), [(0, 0, "center", "center")]
elif geom.geom_type in ("LineString", "MultiLineString"):
anchor = geom.interpolate(0.5, normalized=True)
positions = [(0, pad, "center", "bottom"), (0, -pad, "center", "top")]
else:
anchor = geom if geom.geom_type == "Point" else geom.representative_point()
positions = [(pad, pad, "left", "bottom"), (-pad, pad, "right", "bottom"),
(pad, -pad, "left", "top"), (-pad, -pad, "right", "top")]
text = ax.annotate(str(row[name_col]), (anchor.x, anchor.y), xytext=(0, 0),
textcoords="offset points", fontsize=fontsize,
color="#1e293b", zorder=10,
path_effects=[pe.withStroke(linewidth=halo,
foreground="white")])
for dx, dy, ha, va in positions:
text.set(ha=ha, va=va)
text.xyann = (dx, dy)
fig.canvas.draw()
bb = text.get_window_extent(renderer=renderer)
rect = (bb.x0, bb.y0, bb.x1, bb.y1)
inside = (rect[0] >= axis_box.x0 and rect[2] <= axis_box.x1
and rect[1] >= axis_box.y0 and rect[3] <= axis_box.y1)
if inside and not any(overlaps(rect, other) for other in placed):
placed.append(rect)
break
else:
text.remove()
dropped.append(str(row[name_col]))
print(f"{len(placed)} labels placed, {len(dropped)} dropped")
if dropped:
print(" " + ", ".join(dropped[:8]) + ("…" if len(dropped) > 8 else ""))
return placed, dropped
Example 2 — leader lines for the labels that will not fit
def label_with_leader(ax, anchor, name, direction=(1, 1), distance=28,
fontsize=7, colour="#475569"):
"""Push the label away from a crowded anchor and connect it with a line."""
dx, dy = direction
text = 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,
)
return text
Leader lines are the escape valve for the crowded corner of a map. Use them sparingly — half a dozen on a figure — because each one is a line the reader has to trace.
Example 3 — measuring the crowding before you start
def crowding_report(ax, gdf, name_col, fontsize=7):
"""How many of these labels could possibly fit?"""
fig = ax.get_figure()
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
widths = []
for name in gdf[name_col].astype(str).head(50):
probe = ax.text(0, 0, name, fontsize=fontsize, transform=ax.transAxes)
fig.canvas.draw()
bb = probe.get_window_extent(renderer=renderer)
widths.append((bb.width, bb.height))
probe.remove()
import statistics
w = statistics.median(x for x, _ in widths)
h = statistics.median(y for _, y in widths)
axis_box = ax.get_window_extent(renderer=renderer)
tile = int((axis_box.width * axis_box.height) / (w * h))
print(f"median label {w:.0f} × {h:.0f} px")
print(f"plotting area {axis_box.width:.0f} × {axis_box.height:.0f} px")
print(f"tiling capacity {tile}")
print(f"practical ceiling {tile // 6} labels with usable white space")
return tile
fig, ax = new_map()
capacity = crowding_report(ax, cities.nlargest(50, "POP_MAX"), "NAME")
plt.close(fig)
median label 36 × 10 px
plotting area 512 × 462 px
tiling capacity 637
practical ceiling 106 labels with usable white space
Example 4 — how many labels survive, and what no placement does
import itertools
def naive_overlaps(n):
"""Every label at the upper right of its point, then count colliding pairs."""
fig, ax = new_map()
boxes = []
for _, row in cities.nlargest(n, "POP_MAX").iterrows():
t = ax.annotate(row.NAME, (row.geometry.x, row.geometry.y), xytext=(4, 4),
textcoords="offset points", fontsize=7)
fig.canvas.draw()
bb = t.get_window_extent(renderer=fig.canvas.get_renderer())
boxes.append((bb.x0, bb.y0, bb.x1, bb.y1))
plt.close(fig)
return sum(overlaps(a, b) for a, b in itertools.combinations(boxes, 2))
import time
fig, ax = new_map()
started = time.perf_counter()
label_points(ax, cities, "NAME", "POP_MAX", max_labels=60)
print(f" sixty labels placed in {time.perf_counter() - started:.2f} s")
plt.close(fig)
for n in (50, 100, 200):
fig, ax = new_map()
placed, dropped = label_points(ax, cities, "NAME", "POP_MAX", max_labels=n)
plt.close(fig)
print(f" {n} candidates: kept {len(placed) / n:.0%}; with no placement logic "
f"{naive_overlaps(n)} overlapping pairs")
54 placed, 6 dropped
sixty labels placed in 4.71 s
48 placed, 2 dropped
50 candidates: kept 96%; with no placement logic 9 overlapping pairs
85 placed, 15 dropped
100 candidates: kept 85%; with no placement logic 80 overlapping pairs
142 placed, 58 dropped
200 candidates: kept 71%; with no placement logic 248 overlapping pairs
Why scatter-plot repellers struggle on maps
Why adjustText and friends are not always the answer
Libraries that iteratively push labels apart produce good results on scatter plots, where the anchors are abstract. On maps they have a specific failure: a label pushed away from its anchor can end up nearer a different feature, and a reader will attach it to that one.
Greedy placement with a small set of conventional offsets keeps every label in a position the reader can associate with the right anchor, and drops the rest. On a map, dropping is usually better than moving.
Why a rendered figure is required
Text extent depends on the font, the size, the DPI and the backend's hinting. Matplotlib does not know how wide a string is until a renderer has laid it out, so get_window_extent needs fig.canvas.draw() to have run.
That is also why label placement cannot be computed once and reused across figure sizes: the same map at 8 inches and at 4 inches has different collisions.
Why polygons need a minimum size test
A label wider than its polygon is not a placement problem, it is a selection problem: the feature cannot hold its own name at this scale.
The test is a fraction of total map area rather than an absolute size, so it travels between maps. Anything below it goes to a leader line, a key, or nothing — and reporting the count tells you whether the map needs a different extent.
Why the halo is worth two points of line width
Every label that would otherwise have to avoid a boundary, a coastline or a colour edge becomes placeable. In practice a halo roughly doubles how many labels a busy map can carry, because it removes the background from the constraint set.
It has a cost: at large numbers of labels the halos begin to erode the map underneath. Around 2 points is the balance for 7 pt type; more starts to look like a fog.
Final extents, removal and font fallback
- Label after the extent is final. Every rectangle is invalid if the axes change.
text.remove()rather thanset_visible(False)if you re-render — invisible text still occupies memory and can confuse later extent queries.- Long names dominate. Consider abbreviating in a dedicated column rather than truncating at draw time.
representative_point()for polygons — the centroid can fall outside the shape.- Font fallback changes extents. A missing glyph substitutes a different font with different metrics.
- Check the exported file, not the screen — DPI differences move labels.
- Sort ties deterministically, or two runs produce different maps from the same data.
- Cache placements for a map series only if every panel shares the extent and size.
Internal links
- Labelling explained: why automatic map labels collide — the reasoning and the measurements
- Fixing map labels that overlap or get clipped — diagnosing an existing map
- How to add map legends and labels — the simpler GeoPandas route
- How to build a print-ready map layout in Matplotlib — labels inside a full layout
- Visual hierarchy explained: what a map reader sees first — where labels sit in the hierarchy
- Fixing map text that is too small in the exported file — the size half of the problem
- How to make a map series with consistent symbology — labelling many panels
- How to plot maps with GeoPandas and Matplotlib — the base map this labels
FAQ
Does matplotlib place map labels automatically?
No. ax.text() and ax.annotate() draw where told. Any collision avoidance has to be written, which is about forty lines.
How do I detect label collisions?
Render the figure, get a renderer, and compare get_window_extent() rectangles in device coordinates. Text size does not scale with the data, so collisions must be tested in pixels.
How many candidate positions should each label have?
Four diagonal offsets, tried upper-right first. That kept 96% of 50 labels and 71% of 200 in measurement, and every label stays in a position readers expect.
Should I move labels or drop them?
Drop them. A label pushed far from its anchor can end up nearer a different feature, and the reader will attach it to the wrong one.
How do I label a polygon that is too small for its name?
Use a leader line, a key, or leave it out. Test the polygon's area as a fraction of the map's total area and report how many were skipped.
Why do my labels move when I export the figure?
Because extents depend on DPI and figure size. Place labels after the final size is set, and check the exported file rather than the screen.