How to Label Map Features Without Overlaps in Matplotlib
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 783 candidate places, a greedy placer with four candidate positions kept 92% of 50 labels, 85% of 100 and 74% of 200 โ against 9, 67 and 204 overlapping pairs with no placement logic at all.
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),
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
Step-by-step solution
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 โ a few hundred milliseconds for sixty labels โ 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.
Code examples
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),
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
median label 78 ร 10 px
plotting area 620 ร 462 px
tiling capacity 354
practical ceiling 59 labels with usable white space
Explanation
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.
Edge cases or notes
- 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 92% of 50 labels and 74% 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.