How to Add a Scale Bar and North Arrow to a Python Map
Problem statement
Matplotlib has no scale bar and no north arrow. Neither does GeoPandas. Every map that needs one either imports a helper library or draws it by hand, and both routes have the same trap: a scale bar drawn without checking the projection is frequently wrong.
Measured in Web Mercator โ the CRS most web basemaps use and therefore the one most maps inherit โ the ratio of grid distance to true ground distance along a parallel:
latitude grid metres true metres ratio
0ยฐ 111,319 111,319 1.00
30ยฐ 111,319 96,486 1.15
45ยฐ 111,319 78,846 1.41
52ยฐ 111,319 68,677 1.62
60ยฐ 111,319 55,799 2.00
A bar labelled "100 km" on a Web Mercator map of Europe is right nowhere except one latitude. Drawing it in a projected CRS chosen for the extent makes it right everywhere; drawing it without checking makes it a confident lie.
Quick answer
Check the projection first, then draw the bar in data units:
from pyproj import CRS
def add_scale_bar(ax, crs, length_m=None, location=(0.06, 0.06), height=0.012,
fontsize=7, colour="#1e293b"):
"""A scale bar in map units. Refuses to draw on a geographic CRS."""
crs = CRS.from_user_input(crs)
if crs.is_geographic:
raise ValueError("degrees are not a distance โ reproject before drawing a bar")
x0, x1 = ax.get_xlim()
y0, y1 = ax.get_ylim()
span = x1 - x0
length_m = length_m or nice_round(span * 0.25)
bx = x0 + (x1 - x0) * location[0]
by = y0 + (y1 - y0) * location[1]
bh = (y1 - y0) * height
for i in range(2): # two alternating segments
ax.add_patch(plt.Rectangle(
(bx + i * length_m / 2, by), length_m / 2, bh,
facecolor=colour if i == 0 else "white",
edgecolor=colour, linewidth=0.8, zorder=9))
label = f"{length_m / 1000:,.0f} km" if length_m >= 1000 else f"{length_m:,.0f} m"
ax.text(bx + length_m / 2, by + bh * 1.6, label, ha="center", va="bottom",
fontsize=fontsize, color=colour, zorder=9)
return length_m
def nice_round(value):
"""1, 2 or 5 times a power of ten โ the numbers scale bars are allowed to use."""
import math
exp = math.floor(math.log10(value))
for base in (1, 2, 5, 10):
if value <= base * 10 ** exp:
return base * 10 ** exp
return 10 ** (exp + 1)
Step-by-step solution
1. Check whether a single bar can be correct at all
A scale bar asserts one ratio for the whole map. That holds when the projection preserves distance well across the extent, and fails when it does not:
from pyproj import Geod, Transformer
def scale_variation(crs, bounds, samples=3):
"""Ratio of grid distance to true distance at several latitudes."""
to_wgs = Transformer.from_crs(crs, 4326, always_xy=True)
geod = Geod(ellps="WGS84")
minx, miny, maxx, maxy = bounds
ratios = []
for i in range(samples):
y = miny + (maxy - miny) * i / (samples - 1)
x1, x2 = minx, minx + (maxx - minx) * 0.1
lon1, lat1 = to_wgs.transform(x1, y)
lon2, lat2 = to_wgs.transform(x2, y)
_, _, true_m = geod.inv(lon1, lat1, lon2, lat2)
ratios.append((x2 - x1) / true_m)
return min(ratios), max(ratios)
Measured on three real cases: British National Grid across Britain varies by 0.0%; a US Albers equal-area projection across the conterminous United States varies by 2.5%; Web Mercator across Europe varies by 135.9%.
The first two are fine. The third means no honest single bar exists.
2. Reproject before drawing, not after
If the variation is unacceptable, the fix is the CRS. Choose one appropriate for the extent โ a national grid, a UTM zone, an equidistant conic or an equal-area projection for the region โ and reproject every layer to it before plotting.
Reprojecting for display is not a compromise; it is what makes the map's geometry mean something.
3. Draw the bar in data units, not in figure units
A bar drawn in axes coordinates (transAxes) stays the same size when the extent changes, which makes it wrong. A bar drawn in data units is a true distance and scales with the map automatically.
The corollary is that the bar's height should be in axes-relative units โ it is a graphical element, not a distance โ while its length is in data units.
4. Pick a round number, and let it choose itself
A bar labelled "23.7 km" is unreadable. Round to 1, 2 or 5 times a power of ten, based on roughly a quarter of the map width, and let the code pick.
Two alternating segments is the conventional design: it lets a reader measure half the bar without a ruler.
5. Add a north arrow only if the orientation is in doubt
The arrow is trivial to draw and usually unnecessary. It earns its place on a rotated plan, a polar map, or a projection where meridians converge visibly across the extent.
On a north-up map in a projected CRS whose central meridian runs through the map, north is up everywhere and the arrow says nothing.
6. Check both elements after export
Both are sized in a mixture of data units, points and axes fractions, and export can change the relationship. Look at the exported PDF or PNG rather than the on-screen figure โ particularly if the figure is going into a column narrower than the figure size.
Code examples
Example 1 โ a scale bar that refuses to be wrong
import matplotlib.pyplot as plt
from pyproj import CRS, Geod, Transformer
def safe_scale_bar(ax, gdf, length_m=None, tolerance=0.05, **kwargs):
"""Draw a scale bar, or explain why one would be dishonest here."""
crs = CRS.from_user_input(gdf.crs)
if crs.is_geographic:
raise ValueError(
f"{crs.name} is geographic โ a bar in degrees is not a distance. "
f"Reproject to a projected CRS for the extent first.")
lo, hi = scale_variation(crs, ax.get_xlim() + ax.get_ylim())
spread = hi / lo - 1
if spread > tolerance:
raise ValueError(
f"scale varies by {100 * spread:.0f}% across this extent "
f"({lo:.3f}โ{hi:.3f}). A single bar cannot be correct. "
f"Reproject, or state the scale in words with its latitude.")
if spread > 0.01:
print(f"note: scale varies by {100 * spread:.1f}% across the map")
return add_scale_bar(ax, crs, length_m=length_m, **kwargs)
Raising rather than warning is the right default here. A wrong scale bar is not a cosmetic defect โ the reader will measure with it.
Example 2 โ a north arrow drawn from the projection, not assumed
import numpy as np
from pyproj import Transformer
def true_north_angle(crs, x, y, delta=1000.0):
"""The screen angle of true north at a point, in degrees clockwise from up.
On many projections this varies across the map โ which is itself the test
for whether one arrow can represent the whole figure.
"""
to_wgs = Transformer.from_crs(crs, 4326, always_xy=True)
to_crs = Transformer.from_crs(4326, crs, always_xy=True)
lon, lat = to_wgs.transform(x, y)
nx, ny = to_crs.transform(lon, min(lat + 0.01, 89.9))
return float(np.degrees(np.arctan2(nx - x, ny - y)))
def add_north_arrow(ax, crs, location=(0.93, 0.90), size=0.05, fontsize=8):
x0, x1 = ax.get_xlim()
y0, y1 = ax.get_ylim()
corners = [(x0, y0), (x1, y0), (x0, y1), (x1, y1)]
angles = [true_north_angle(crs, x, y) for x, y in corners]
spread = max(angles) - min(angles)
if spread > 2.0:
print(f"warning: true north varies by {spread:.1f}ยฐ across this map โ "
f"one arrow is an approximation")
ax.annotate("N", xy=location, xytext=(location[0], location[1] - size),
xycoords="axes fraction", textcoords="axes fraction",
ha="center", va="center", fontsize=fontsize, color="#1e293b",
arrowprops=dict(arrowstyle="-|>", color="#1e293b", linewidth=1.1))
return spread
The warning is the useful part. If true north differs by ten degrees between the corners, the arrow is decoration and the map should say which meridian it applies to.
Example 3 โ scale in words, for the maps where a bar cannot work
def scale_statement(crs, ax, figure_width_in, latitude=None):
"""'1:2,300,000 at 52ยฐ N' โ honest where a bar is not."""
from pyproj import CRS, Geod, Transformer
x0, x1 = ax.get_xlim()
span_map_units = x1 - x0
span_inches = figure_width_in
ratio = span_map_units / (span_inches * 0.0254)
text = f"1:{ratio:,.0f}"
if latitude is not None:
to_wgs = Transformer.from_crs(CRS.from_user_input(crs), 4326, always_xy=True)
geod = Geod(ellps="WGS84")
lon1, lat1 = to_wgs.transform(x0, ax.get_ylim()[0])
lon2, _ = to_wgs.transform(x0 + span_map_units * 0.1, ax.get_ylim()[0])
_, _, true_m = geod.inv(lon1, lat1, lon2, lat1)
corrected = ratio * (span_map_units * 0.1) / true_m
text = f"1:{corrected:,.0f} at {latitude:g}ยฐ N"
return text
A representative fraction with the latitude attached is honest on a conformal map where a bar is not, and it takes one line of 6 pt type.
Explanation
Why Web Mercator breaks scale bars specifically
Web Mercator is conformal: it preserves angles and local shape, at the cost of scale. Scale is correct along the equator and inflates as 1/cos(latitude) going north or south โ which is why Greenland looks continental.
That factor is exactly the ratio table above: 1.15 at 30ยฐ, 1.41 at 45ยฐ, 2.00 at 60ยฐ. A scale bar is a statement about distance, so it inherits the whole error. The map still looks right, because shape is preserved, which is what makes the failure so easy to ship.
Why a bar in data units is the only correct implementation
A bar defined as a fraction of the axes stays the same length on screen when the extent changes, so its ground meaning changes silently โ zoom in and the "100 km" bar now spans 40 km.
Defining it in data units ties it to the projection's coordinate system, which is the thing that actually has a scale. It also means the bar is automatically correct when the map is reused at a different extent, which is what happens in a map series.
Why 1, 2 and 5 are the only lengths a bar should use
Readers estimate intermediate distances by subdividing the bar mentally. That works for 1, 2 and 5 times a power of ten, and fails for 3, 7 or 23.7.
The convention is old and it is the reason the automatic length selection is worth eight lines: a bar whose length was chosen by an algorithm that did not know the rule looks amateurish and is genuinely harder to use.
Why north arrows deserve a variation check
On a national grid with the central meridian inside the map, true north is within a fraction of a degree of grid north everywhere, and one arrow is exact. On a wide conic or an azimuthal projection, meridians converge and true north differs measurably between the corners.
The true_north_angle function above turns that from a judgement into a number. Above a couple of degrees, either draw a curved graticule instead or state the meridian the arrow refers to.
Edge cases or notes
- Never draw a bar on a geographic CRS. Degrees are not a distance, and the error varies with latitude.
- UTM is fine within its zone and increasingly wrong outside it.
- Check the exported file. A figure scaled into a narrow column scales the bar too โ the ratio stays right, the label may become illegible.
- Two segments, not five. More segments make the bar look like a ruler and take more space.
- Put the bar over a plain background, or give it a white box; over a choropleth it becomes unreadable.
- Interactive maps need a dynamic bar that recomputes on zoom โ a static one is wrong at every other zoom level.
- Kilometres or miles, not both unless the audience genuinely needs both.
- A representative fraction is meaningless on screen, where DPI is unknown; use it only for print.
Internal links
- Which map elements are actually required โ whether either element belongs on this map
- Map projection for display explained โ choosing a CRS that makes a bar honest
- How to choose a projected CRS for an area โ the reprojection decision
- How to build a print-ready map layout in Matplotlib โ where these elements sit
- Projected versus geographic CRS explained โ why degrees are not metres
- How to measure distance accurately โ the same arithmetic applied to data
- Visual hierarchy explained: what a map reader sees first โ keeping the apparatus quiet
- How to build a reusable map style module โ packaging these helpers
FAQ
Does matplotlib have a scale bar?
No, and neither does GeoPandas. You draw one, or import a helper โ and either way the projection check is your responsibility.
Why is my scale bar wrong on a web-basemap map?
Because Web Mercator inflates distance by 1/cos(latitude): 1.15 at 30ยฐ, 1.41 at 45ยฐ, 2.00 at 60ยฐ. One bar cannot be right across a map spanning those latitudes.
What length should a scale bar be?
Roughly a quarter of the map width, rounded to 1, 2 or 5 times a power of ten. Readers subdivide those mentally; they cannot subdivide 23.7 km.
Should the bar be in data units or figure units?
Data units. A bar in figure units keeps its screen length and changes its ground meaning whenever the extent changes.
Do I need a north arrow?
Only if the orientation is genuinely in doubt โ a rotated plan, a polar map, or a projection where north varies across the extent. Measure the variation before deciding.
What do I do when no honest scale bar exists?
Reproject to a CRS suited to the extent, or state the scale in words with the latitude it applies to: "1:2,300,000 at 52ยฐ N".