How to Add Legends, Labels and a Scale Bar to a GeoPandas Map
Problem statement
The map is correct and nobody can read it:
gdf.plot(column="income", cmap="YlOrRd", legend=True)
The legend is a vertical colour bar the full height of the figure, labelled 50000.00000, 100000.00000, in a font too small to read. There is no indication of what the colours mean beyond a number. No place names. No scale. No north arrow. No source.
Then you try to add labels:
for row in gdf.itertuples():
ax.text(row.geometry.centroid.x, row.geometry.centroid.y, row.ward_name)
and get 340 overlapping strings, several of them outside their own polygon, one of them in the North Sea.
Everything that turns a plot into a map is manual matplotlib work. The good news is that it is a fixed, small set of moves.
Quick answer
import geopandas as gpd
import matplotlib.pyplot as plt
import matplotlib.patheffects as pe
gdf = gpd.read_file("wards.gpkg").to_crs(27700)
fig, ax = plt.subplots(figsize=(10, 11))
gdf.plot(column="income", scheme="quantiles", k=5, cmap="YlOrRd", ax=ax,
edgecolor="white", linewidth=0.3, legend=True,
legend_kwds={"loc": "lower right", "fontsize": 9,
"title": "Median income (Β£)", "fmt": "{:,.0f}",
"frameon": True, "framealpha": 0.9})
# labels for the ten largest only, placed inside the polygon
for row in gdf.nlargest(10, "area_km2").itertuples():
p = row.geometry.representative_point()
ax.annotate(row.ward_name, (p.x, p.y), ha="center", fontsize=8,
path_effects=[pe.withStroke(linewidth=2.5, foreground="white")])
ax.set_title("Median household income by ward", fontsize=14, loc="left")
ax.set_axis_off()
ax.annotate("Source: ONS, 2026 Β· EPSG:27700", xy=(0.01, 0.01),
xycoords="axes fraction", fontsize=7, color="#64748b")
| Element | How |
|---|---|
| classed legend | legend=True with a scheme |
| colour bar | legend=True without a scheme, plus legend_kwds |
| labels | ax.annotate at representative_point(), with a halo |
| scale bar | a Rectangle in data coordinates β projected CRS only |
| north arrow | ax.annotate with an arrow, in axes coordinates |
| source and CRS | ax.annotate in axes coordinates |
Step-by-step solution
1. Get the right kind of legend
The difference is decided by whether you pass a scheme:
# continuous β a colour bar
gdf.plot(column="income", cmap="YlOrRd", legend=True, ax=ax)
# classified β a discrete legend with one entry per class
gdf.plot(column="income", scheme="quantiles", k=5, cmap="YlOrRd", legend=True, ax=ax)
# categorical β one entry per distinct value
gdf.plot(column="land_use", categorical=True, legend=True, ax=ax)
The discrete form is almost always more readable, because a reader matching a polygon's colour to a continuous ramp is doing a task humans are bad at. See choropleth classification explained.
legend_kwds differs between the two, which is a frequent source of confusion:
# discrete legend β these go to matplotlib's Legend
legend_kwds={
"loc": "lower right", "title": "Median income (Β£)", "fontsize": 9,
"title_fontsize": 10, "fmt": "{:,.0f}", "frameon": True,
"framealpha": 0.9, "labels": ["very low", "low", "medium", "high", "very high"],
}
# colour bar β these go to Figure.colorbar
legend_kwds={
"label": "Median income (Β£)", "orientation": "horizontal",
"shrink": 0.5, "pad": 0.02, "format": "{x:,.0f}",
}
"fmt" for the discrete legend and "format" for the colour bar. Passing the wrong one is silently ignored, which is why so many published maps carry 50000.00000 in their legend.
2. Tame the colour bar when you must use one
fig, ax = plt.subplots(figsize=(10, 10))
gdf.plot(column="elevation", cmap="terrain", legend=True, ax=ax,
legend_kwds={"label": "Elevation (m)", "orientation": "horizontal",
"shrink": 0.45, "pad": 0.01, "aspect": 30,
"format": "{x:,.0f}"})
shrink and aspect are what stop a colour bar dominating the figure. For finer control, place it explicitly:
from mpl_toolkits.axes_grid1 import make_axes_locatable
divider = make_axes_locatable(ax)
cax = divider.append_axes("right", size="3%", pad=0.1)
gdf.plot(column="elevation", cmap="terrain", ax=ax, legend=True, cax=cax)
cax.set_ylabel("Elevation (m)", fontsize=9)
make_axes_locatable keeps the colour bar the same height as the map, which the default does not β a common reason a map looks unbalanced.
3. Place labels where they belong, and only some of them
Three rules turn unreadable labels into useful ones.
Use representative_point(), not centroid. A centroid of a concave or ring-shaped polygon can fall outside it; a representative point is guaranteed inside:
p = row.geometry.representative_point()
ax.annotate(row.ward_name, (p.x, p.y), ha="center", va="center", fontsize=8)
Add a halo so text stays readable over any fill:
import matplotlib.patheffects as pe
ax.annotate(row.ward_name, (p.x, p.y), ha="center", va="center", fontsize=8,
color="#1e293b",
path_effects=[pe.withStroke(linewidth=2.5, foreground="white")])
Label a subset. Three hundred and forty labels is not a map, it is a wall of text. Pick by importance:
# the largest by area β they have room for the text
for row in gdf.nlargest(12, "area_km2").itertuples():
...
# or the notable ones by value
for row in gdf.nlargest(8, "income").itertuples():
...
# or only those big enough on the page to hold their label
minx, maxx = ax.get_xlim()
scale = (maxx - minx) / fig.get_size_inches()[0] / 72 # data units per point
big_enough = gdf[gdf.geometry.area ** 0.5 > 40 * scale]
That last filter is the general answer: a label needs the polygon to be wider than the text, and how wide the text is depends on the figure size and the axis extent, not on the data.
4. Add a scale bar β in a projected CRS only
from matplotlib.patches import Rectangle
def scale_bar(ax, length_m=5000, location=(0.06, 0.06), height_frac=0.006,
fontsize=9, color="black"):
"""A scale bar. The axes MUST be in a projected CRS measured in metres."""
x0, x1 = ax.get_xlim()
y0, y1 = ax.get_ylim()
bx = x0 + (x1 - x0) * location[0]
by = y0 + (y1 - y0) * location[1]
h = (y1 - y0) * height_frac
# two segments, alternating fill, so the bar reads as a ruler
ax.add_patch(Rectangle((bx, by), length_m / 2, h,
facecolor=color, edgecolor=color, zorder=10))
ax.add_patch(Rectangle((bx + length_m / 2, by), length_m / 2, h,
facecolor="white", edgecolor=color, zorder=10))
label = f"{length_m/1000:g} km" if length_m >= 1000 else f"{length_m:g} m"
for frac, text in [(0, "0"), (1, label)]:
ax.text(bx + length_m * frac, by + h * 2.2, text,
ha="center", va="bottom", fontsize=fontsize, zorder=10)
scale_bar(ax, length_m=5000)
The correctness condition is not optional. A scale bar draws a fixed number of data units, so it is only meaningful when one data unit is one metre everywhere on the map. That is true in a national grid or a UTM zone, and false in:
- EPSG:4326, where the units are degrees and a degree of longitude is 111 km at the equator and 55 km at 60Β°N.
- EPSG:3857, where the units are metres but the scale varies by 1/cos(latitude) β about 61% error at British latitudes.
if gdf.crs is None or gdf.crs.is_geographic:
raise ValueError("a scale bar needs a projected CRS")
if gdf.crs.to_epsg() == 3857:
print("warning: Web Mercator scale varies with latitude β the bar is approximate")
Choosing a round length that occupies roughly a fifth of the map width:
def nice_length(ax):
span = ax.get_xlim()[1] - ax.get_xlim()[0]
target = span / 5
for step in [10, 20, 50, 100, 200, 500, 1_000, 2_000, 5_000,
10_000, 20_000, 50_000, 100_000, 200_000, 500_000]:
if step >= target:
return step
return 1_000_000
5. Add a north arrow β and know when not to
def north_arrow(ax, location=(0.94, 0.92), size=0.05, fontsize=11):
ax.annotate("N", xy=location, xytext=(location[0], location[1] - size),
xycoords="axes fraction", textcoords="axes fraction",
ha="center", va="center", fontsize=fontsize, fontweight="bold",
arrowprops=dict(arrowstyle="-|>", facecolor="black", linewidth=1.4),
zorder=10)
north_arrow(ax)
Axes-fraction coordinates keep it pinned to the corner regardless of the data extent.
A single straight arrow assumes north is the same direction everywhere on the map, which is true for a north-up projection over a small area and false for a conic projection over a continent, where meridians converge visibly. On a continental map, a graticule is the honest alternative:
# meridians and parallels, which show convergence rather than hiding it
import numpy as np
from shapely.geometry import LineString
grat = gpd.GeoDataFrame(geometry=[
*[LineString([(lon, la) for la in np.linspace(-89, 89, 180)])
for lon in range(-180, 181, 10)],
*[LineString([(lo, lat) for lo in np.linspace(-180, 180, 360)])
for lat in range(-80, 81, 10)],
], crs=4326).to_crs(gdf.crs)
grat.plot(ax=ax, color="#cbd5e1", linewidth=0.4, zorder=0)
6. Credit the source and name the CRS
ax.annotate("Source: ONS mid-2026 estimates Β· Boundaries Β© Crown copyright\n"
f"{gdf.crs.name} (EPSG:{gdf.crs.to_epsg()}) Β· quantiles, k=5",
xy=(0.005, 0.005), xycoords="axes fraction",
fontsize=7, color="#64748b", va="bottom")
The CRS and the classification belong here as much as the data source. A reader who can see "Web Mercator, quantiles, k=5" knows how to interpret both the shapes and the colours; without it they are trusting choices they cannot inspect.
Code examples
Example 1: one function that adds all the furniture
import geopandas as gpd
import matplotlib.pyplot as plt
import matplotlib.patheffects as pe
from matplotlib.patches import Rectangle
def finish_map(ax, gdf, *, title=None, subtitle=None, source=None,
scale=True, north=True, label_col=None, label_n=10,
label_by="area", fontsize=8):
"""Add title, labels, scale bar, north arrow and credits to a plotted map."""
crs = gdf.crs
if title:
ax.set_title(title, fontsize=15, loc="left", pad=14 if subtitle else 6)
if subtitle:
ax.annotate(subtitle, xy=(0, 1.005), xycoords="axes fraction",
fontsize=9.5, color="#475569", va="bottom")
if label_col:
ranked = (gdf.assign(_a=gdf.geometry.area)
.nlargest(label_n, "_a" if label_by == "area" else label_by))
for row in ranked.itertuples():
p = row.geometry.representative_point()
ax.annotate(str(getattr(row, label_col)), (p.x, p.y),
ha="center", va="center", fontsize=fontsize, color="#1e293b",
zorder=9,
path_effects=[pe.withStroke(linewidth=2.5, foreground="white")])
if scale:
if crs is None or crs.is_geographic:
print(" Β· scale bar skipped: axes are not in a projected CRS")
else:
span = ax.get_xlim()[1] - ax.get_xlim()[0]
length = next((s for s in [10, 20, 50, 100, 200, 500, 1_000, 2_000,
5_000, 10_000, 20_000, 50_000, 100_000,
200_000, 500_000] if s >= span / 5), 1_000_000)
x0, x1 = ax.get_xlim(); y0, y1 = ax.get_ylim()
bx, by = x0 + (x1 - x0) * 0.06, y0 + (y1 - y0) * 0.06
h = (y1 - y0) * 0.006
ax.add_patch(Rectangle((bx, by), length / 2, h,
facecolor="black", edgecolor="black", zorder=10))
ax.add_patch(Rectangle((bx + length / 2, by), length / 2, h,
facecolor="white", edgecolor="black", zorder=10))
unit = f"{length/1000:g} km" if length >= 1000 else f"{length:g} m"
ax.text(bx, by + h * 2.2, "0", ha="center", va="bottom",
fontsize=8, zorder=10)
ax.text(bx + length, by + h * 2.2, unit, ha="center", va="bottom",
fontsize=8, zorder=10)
if crs.to_epsg() == 3857:
ax.text(bx + length / 2, by - h * 3,
"approximate β Web Mercator", ha="center", va="top",
fontsize=6.5, color="#d97706", zorder=10)
if north:
ax.annotate("N", xy=(0.945, 0.94), xytext=(0.945, 0.885),
xycoords="axes fraction", textcoords="axes fraction",
ha="center", va="center", fontsize=11, fontweight="bold",
arrowprops=dict(arrowstyle="-|>", facecolor="black",
linewidth=1.4), zorder=10)
credit = source or ""
if crs is not None:
code = crs.to_epsg()
credit += f"{' Β· ' if credit else ''}{crs.name}" + (f" (EPSG:{code})" if code else "")
if credit:
ax.annotate(credit, xy=(0.005, 0.005), xycoords="axes fraction",
fontsize=7, color="#64748b", va="bottom")
ax.set_axis_off()
return ax
gdf = gpd.read_file("wards.gpkg").to_crs(27700)
fig, ax = plt.subplots(figsize=(10, 11))
gdf.plot(column="income", scheme="quantiles", k=5, cmap="YlOrRd", ax=ax,
edgecolor="white", linewidth=0.3, legend=True,
legend_kwds={"loc": "lower right", "title": "Median income (Β£)",
"fmt": "{:,.0f}", "fontsize": 9, "framealpha": 0.9},
missing_kwds={"color": "#e2e8f0", "hatch": "///", "label": "no data"})
finish_map(ax, gdf, title="Median household income",
subtitle="By electoral ward, 2026",
source="Source: ONS Β· Boundaries Β© Crown copyright",
label_col="ward_name", label_n=12)
fig.savefig("income.png", dpi=200, bbox_inches="tight", facecolor="white")
Two behaviours are worth highlighting. The scale bar is skipped with a printed reason on a geographic CRS rather than drawn wrongly β a wrong scale bar is worse than no scale bar, because a reader will believe it. And on Web Mercator it is drawn with an "approximate" note, since the axes really are in metres but the scale varies with latitude.
bbox_inches="tight" with facecolor="white" is the export combination that avoids both a large white margin and a transparent background that turns black in a dark-mode viewer.
Example 2: labels that do not collide
import geopandas as gpd
import matplotlib.pyplot as plt
import matplotlib.patheffects as pe
from shapely.geometry import box
def place_labels(ax, gdf, col, *, fontsize=8, max_labels=40, min_gap_pts=6):
"""Greedy placement: biggest first, skipping any label that would overlap."""
fig = ax.figure
renderer = fig.canvas.get_renderer()
placed_boxes = []
placed = 0
ranked = gdf.assign(_a=gdf.geometry.area).sort_values("_a", ascending=False)
for row in ranked.itertuples():
if placed >= max_labels:
break
text = str(getattr(row, col))
p = row.geometry.representative_point()
artist = ax.annotate(text, (p.x, p.y), ha="center", va="center",
fontsize=fontsize, zorder=9,
path_effects=[pe.withStroke(linewidth=2.5,
foreground="white")])
bb = artist.get_window_extent(renderer=renderer).expanded(1.0, 1.0)
bb = box(bb.x0 - min_gap_pts, bb.y0 - min_gap_pts,
bb.x1 + min_gap_pts, bb.y1 + min_gap_pts)
if any(bb.intersects(other) for other in placed_boxes):
artist.remove() # would collide β drop it
continue
placed_boxes.append(bb)
placed += 1
print(f" placed {placed} of {len(gdf)} labels")
return placed
gdf = gpd.read_file("wards.gpkg").to_crs(27700)
fig, ax = plt.subplots(figsize=(11, 12))
gdf.plot(ax=ax, facecolor="#f8fafc", edgecolor="#cbd5e1", linewidth=0.4)
ax.set_axis_off()
fig.canvas.draw() # needed before measuring text
place_labels(ax, gdf, "ward_name")
placed 31 of 340 labels
The trick is measuring text in screen coordinates with get_window_extent, not in data coordinates. Text size is fixed in points and does not scale with the axes, so an overlap test in data units gives the wrong answer at every zoom level. fig.canvas.draw() before measuring is required β the renderer does not exist until the figure has been drawn once.
Greedy largest-first placement is not optimal, but it is fast and it prioritises the polygons with room for their text. Dedicated packages such as adjustText do better at the cost of a dependency and a slower layout pass.
Example 3: a two-panel figure with one shared legend
import geopandas as gpd
import matplotlib.pyplot as plt
from matplotlib.cm import ScalarMappable
from matplotlib.colors import BoundaryNorm
import mapclassify as mc
gdf = gpd.read_file("wards.gpkg").to_crs(27700)
years = ["income_2016", "income_2026"]
breaks = list(mc.Quantiles(gdf[years].values.ravel(), k=5).bins)
norm = BoundaryNorm(boundaries=[gdf[years].values.min()] + breaks, ncolors=256)
cmap = "YlOrRd"
fig, axes = plt.subplots(1, 2, figsize=(15, 8))
for ax, col in zip(axes, years):
gdf.plot(column=col, cmap=cmap, norm=norm, ax=ax,
edgecolor="white", linewidth=0.25)
ax.set_title(col.replace("income_", ""), fontsize=12)
ax.set_axis_off()
cbar = fig.colorbar(ScalarMappable(norm=norm, cmap=cmap), ax=axes,
orientation="horizontal", shrink=0.5, pad=0.04,
format="{x:,.0f}")
cbar.set_label("Median household income (Β£)", fontsize=10)
fig.suptitle("Median household income, 2016 and 2026", fontsize=15, y=0.97)
One norm shared by both panels is what makes them comparable β without it each panel scales to its own range and identical colours mean different values. fig.colorbar(..., ax=axes) attaches one bar to the pair rather than one per panel, which removes the visual suggestion that the two keys might differ.
Breaks come from the pooled values across both years, so the classification suits the whole comparison rather than either year alone.
Explanation
Everything in this article exists because matplotlib draws data and knows nothing about maps. It has no concept of scale, of north, of a place name, or of what a colour means. GeoPandas adds one convenience β a legend derived from the column being plotted β and everything else is drawn by hand. That is a limitation, and it is also why any of it is possible: a matplotlib axis will accept whatever you construct.
The legend's two forms follow from what is being encoded. A continuous colour ramp maps a value to a colour by interpolation, so its legend must be a continuous bar; a classified map assigns each feature to one of k classes, so its legend is a list of swatches. Because these are different matplotlib objects β a Colorbar and a Legend β they take different keyword arguments, which is why fmt works on one and format on the other, and why passing the wrong one does nothing at all. The discrete form is usually better, because reading a value off a continuous ramp is a task human vision performs badly.
Labels are hard for a reason that is not about GIS. A label has a position in data coordinates and a size in points, and those two systems do not scale together. Zooming the map moves the anchor points but not the text size, so a set of labels that fits at one extent overlaps at another. That is why collision detection has to happen in screen space after the figure is drawn, and why no amount of pre-computation in data units solves it. Placing labels well is a constrained optimisation problem β cartographers have written a great deal about it β and the pragmatic answer is to label few things, chosen for importance, with a halo so that whatever does overlap remains readable.
The scale bar is the element most often wrong. It draws a fixed number of data units and asserts that this length is the same everywhere on the map. That assertion holds in a projected CRS designed for the area, and fails in two common cases: in EPSG:4326 the units are degrees, which are not a length at all; in EPSG:3857 the units are metres but Mercator's scale factor is 1/cos(latitude), so a bar accurate at the map's centre is wrong elsewhere by up to 61% at British latitudes. The distortion is explained in choosing a map projection for display, and the practical rule is simple: draw a scale bar only in a projected CRS, and mark it approximate on Web Mercator.
The north arrow carries a quieter version of the same assumption. A single straight arrow claims north is one direction across the whole map. True for a north-up projection over a small area; false for any conic or azimuthal projection over a continent, where meridians visibly converge. On such maps a graticule tells the truth and an arrow does not.
Finally, the source line is not decoration. A map is an argument, and a reader who can see the data source, the CRS and the classification can evaluate that argument. One who cannot is being asked to trust three decisions they cannot inspect β and those three decisions are, between them, most of what determines what the map appears to say.
Edge cases or notes
legend_kwdskeys differ between the classified legend (fmt,loc,title) and the colour bar (format,label,shrink). Wrong keys are silently ignored.fmt="{:,.0f}"is what removes50000.00000from a classified legend.representative_point()beatscentroidfor labels β a centroid can fall outside a concave polygon.fig.canvas.draw()beforeget_window_extent, or the renderer does not exist yet.- A scale bar in EPSG:4326 is meaningless. In EPSG:3857 it is approximate; label it so.
markersizeis area in pointsΒ², so the legend marker size is not the map marker size unless you set both.bbox_inches="tight"plusfacecolor="white"avoids both a wide margin and a transparent background.make_axes_locatablekeeps a colour bar the same height as the map; the default does not.path_effects=[pe.withStroke(...)]is the halo that makes labels legible over any fill.adjustTextdoes better label placement than greedy largest-first, at the cost of a dependency.
Internal links
- How to plot maps in Python with GeoPandas and Matplotlib β the plot this finishes
- Choropleth classification explained β what the legend is describing
- How to plot multiple layers on one map in GeoPandas β hand-built legends for several layers
- Choosing a map projection for display β why a scale bar needs a projected CRS
- How to save a map as an image in Python with Matplotlib β export settings
- How to add a basemap to a GeoPandas map with contextily β attribution as a legend element
- How to batch-generate a map image for every region β applying one style repeatedly
- How to create a choropleth map in Python with GeoPandas β the map most of this applies to
FAQ
Why is my legend a colour bar instead of classes?
Because no scheme was passed. legend=True alone produces a continuous colour bar; adding scheme="quantiles", k=5 produces a discrete legend with one entry per class.
How do I format the legend numbers?
legend_kwds={"fmt": "{:,.0f}"} for a classified legend, legend_kwds={"format": "{x:,.0f}"} for a colour bar. They are different matplotlib objects and take different keys.
Why are my labels outside their polygons?
You are using centroid, which can fall outside a concave or ring-shaped polygon. Use representative_point(), which is guaranteed inside.
How do I stop labels overlapping?
Label fewer features β the largest or most notable β add a white halo, and drop any label whose screen bounding box collides with one already placed, as in Example 2.
Can I add a scale bar to a map in EPSG:4326?
No. The units are degrees, whose ground length varies with latitude. Reproject to a projected CRS first. On Web Mercator a bar is approximate and should be labelled as such.
How do I put one legend on a multi-panel figure?
Build a shared norm, plot every panel with it, then call fig.colorbar(ScalarMappable(norm=norm, cmap=cmap), ax=axes) to attach one bar to the group.
What should the credit line say?
The data source, the boundary licence if there is one, the CRS, and the classification scheme. Those are the choices a reader needs in order to judge the map.