How to Add a Scale Bar and North Arrow to a Python Map
On this page
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 one degree of each parallel (Example 1 prints it):
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 1.99
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:
import matplotlib.pyplot as plt
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)
What you get โ from The data used on this page
UK, EPSG:27700 1 countries, [2000.0, 18000.0, 655000.0, 1217000.0]
US, EPSG:5070 1 countries, [-2353000.0, 277000.0, 2255000.0, 3165000.0]
Europe, EPSG:3857 50 countries, [-2725000.0, 4155000.0, 5009000.0, 11463000.0]
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:
import numpy as np
from pyproj import CRS, Geod, Transformer
def scale_variation(crs, bounds, samples=5):
"""Smallest and largest ratio of grid to true distance over a grid of points.
Sample across as well as up: on a transverse Mercator scale changes with
distance from the central meridian, not with latitude.
"""
# the CRS's own geographic CRS and ellipsoid: measuring scale needs no datum shift
geographic = CRS.from_user_input(crs).geodetic_crs
to_geo = Transformer.from_crs(crs, geographic, always_xy=True)
geod = geographic.get_geod()
minx, miny, maxx, maxy = bounds
step = (maxx - minx) * 0.01
ratios = []
for fy in np.linspace(0, 1, samples):
for fx in np.linspace(0, 0.99, samples):
x, y = minx + (maxx - minx) * fx, miny + (maxy - miny) * fy
lon1, lat1 = to_geo.transform(x, y)
lon2, lat2 = to_geo.transform(x + step, y)
ratios.append(step / geod.inv(lon1, lat1, lon2, lat2)[2])
return min(ratios), max(ratios)
Measured over the plotted extent of three real maps from Natural Earth (Example 1): the British National Grid across the United Kingdom varies by 0.22%; a US Albers equal-area projection across the conterminous United States by 4.45%; Web Mercator across Europe by 176%.
Two details in the function matter. The grid of sample points: an earlier version sampled only along the map's western edge, and reported 0.0% for Britain โ on a transverse Mercator the scale changes from east to west, away from the central meridian, which an edge never crosses. And the geographic CRS: converting to WGS 84 instead of the grid's own OSGB36 put a datum transformation into a measurement of scale, and in the North Sea, where two ends of one short step fell on either side of the area PROJ's transformation covers, the ratio jumped below anything a transverse Mercator can produce.
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, north is exactly up only along the central meridian, and the meridians converge away from it. How far is measurable: between the corners of the United Kingdom's map in the British National Grid, true north differs by 11.7ยฐ; across the conterminous US in Albers by 41.9ยฐ; across Europe in Lambert azimuthal equal-area by 96.6ยฐ (Example 2). Even on the first, an arrow is exact only near the central meridian; on the last it is decoration.
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
The data used on this page
Country outlines from Natural Earth at 1:50 million, cut into the three extents measured above: the United Kingdom in the British National Grid, the conterminous United States in Albers equal-area (EPSG:5070), and Europe in Web Mercator.
import os
import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import requests
from shapely.geometry import box
if not os.path.exists("ne_50m_admin_0_countries.zip"):
reply = requests.get("https://naciscdn.org/naturalearth/50m/cultural/ne_50m_admin_0_countries.zip",
headers={"User-Agent": "spatialworkflow.io guide"}, timeout=120)
reply.raise_for_status()
with open("ne_50m_admin_0_countries.zip", "wb") as out:
out.write(reply.content)
world = gpd.read_file("ne_50m_admin_0_countries.zip")
cases = {
"UK, EPSG:27700": world[world["ADM0_A3"] == "GBR"].to_crs(27700),
"US, EPSG:5070": world[world["ADM0_A3"] == "USA"].clip(box(-125, 24, -66, 50)).to_crs(5070),
"Europe, EPSG:3857": world[world["CONTINENT"] == "Europe"].clip(box(-25, 34, 45, 72)).to_crs(3857),
}
for name, layer in cases.items():
print(f"{name:18} {len(layer):3} countries, {layer.total_bounds.round(-3).tolist()}")
UK, EPSG:27700 1 countries, [2000.0, 18000.0, 655000.0, 1217000.0]
US, EPSG:5070 1 countries, [-2353000.0, 277000.0, 2255000.0, 3165000.0]
Europe, EPSG:3857 50 countries, [-2725000.0, 4155000.0, 5009000.0, 11463000.0]
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.")
(x0, x1), (y0, y1) = ax.get_xlim(), ax.get_ylim()
lo, hi = scale_variation(crs, (x0, y0, x1, y1)) # minx, miny, maxx, maxy
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)
The Web Mercator ratios at the top of the page, then each extent through the check โ the ones that pass get a bar and are saved:
geod = Geod(ellps="WGS84")
mercator = Transformer.from_crs(4326, 3857, always_xy=True)
for latitude in (0, 30, 45, 52, 60):
grid = mercator.transform(1, latitude)[0] - mercator.transform(0, latitude)[0]
true = geod.inv(0, latitude, 1, latitude)[2]
print(f"{latitude:3}ยฐ grid {grid:9,.0f} m true {true:9,.0f} m ratio {grid / true:.2f}")
for name, layer in cases.items():
fig, ax = plt.subplots(figsize=(7, 5))
layer.plot(ax=ax, color="#e8f4fd", edgecolor="#1a3a6b", linewidth=0.4)
ax.set_axis_off()
(x0, x1), (y0, y1) = ax.get_xlim(), ax.get_ylim()
lo, hi = scale_variation(layer.crs, (x0, y0, x1, y1))
print(f"{name:18} ratio {lo:.4f}โ{hi:.4f}, spread {100 * (hi / lo - 1):.2f}%")
try:
length = safe_scale_bar(ax, layer)
out = f"{name.split(',')[0].lower()}.png"
fig.savefig(out, dpi=150)
print(f"{'':18} a {length / 1000:,.0f} km bar, saved {out}")
except ValueError as refused:
print(f"{'':18} refused: {str(refused).split('. ')[0]}")
plt.close(fig)
0ยฐ grid 111,319 m true 111,319 m ratio 1.00
30ยฐ grid 111,319 m true 96,486 m ratio 1.15
45ยฐ grid 111,319 m true 78,846 m ratio 1.41
52ยฐ grid 111,319 m true 68,677 m ratio 1.62
60ยฐ grid 111,319 m true 55,799 m ratio 1.99
UK, EPSG:27700 ratio 0.9997โ1.0018, spread 0.22%
a 200 km bar, saved uk.png
US, EPSG:5070 ratio 0.9904โ1.0344, spread 4.45%
note: scale varies by 4.5% across the map
a 2,000 km bar, saved us.png
Europe, EPSG:3857 ratio 1.1806โ3.2628, spread 176.36%
refused: scale varies by 176% across this extent (1.181โ3.263)
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 CRS, 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.
"""
geographic = CRS.from_user_input(crs).geodetic_crs
to_geo = Transformer.from_crs(crs, geographic, always_xy=True)
to_crs = Transformer.from_crs(geographic, crs, always_xy=True)
lon, lat = to_geo.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
cases["Europe, EPSG:3035"] = cases["Europe, EPSG:3857"].to_crs(3035)
for name, layer in cases.items():
fig, ax = plt.subplots(figsize=(7, 5))
layer.plot(ax=ax)
print(f"{name:18}", end=" ")
spread = add_north_arrow(ax, layer.crs)
if spread <= 2.0:
print(f"true north varies by {spread:.1f}ยฐ")
plt.close(fig)
UK, EPSG:27700 warning: true north varies by 11.7ยฐ across this map โ one arrow is an approximation
US, EPSG:5070 warning: true north varies by 41.9ยฐ across this map โ one arrow is an approximation
Europe, EPSG:3857 true north varies by 0.0ยฐ
Europe, EPSG:3035 warning: true north varies by 96.6ยฐ across this map โ one arrow is an approximation
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, 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
# the axes, not the figure: an equal-aspect map rarely fills its figure's width
fig = ax.figure
fig.canvas.draw()
span_inches = ax.get_window_extent().width / fig.dpi
ratio = span_map_units / (span_inches * 0.0254) # map units per paper metre
if latitude is None:
return f"1:{ratio:,.0f} in map units"
crs = CRS.from_user_input(crs)
geographic = crs.geodetic_crs
to_geo = Transformer.from_crs(crs, geographic, always_xy=True)
to_map = Transformer.from_crs(geographic, crs, always_xy=True)
lon0 = to_geo.transform(x0, sum(ax.get_ylim()) / 2)[0]
xa, ya = to_map.transform(lon0, latitude) # a point on that parallel
lon1, lat1 = to_geo.transform(xa + span_map_units * 0.1, ya)
true_m = geographic.get_geod().inv(lon0, latitude, lon1, lat1)[2]
return f"1:{ratio * true_m / (span_map_units * 0.1):,.0f} at {latitude:g}ยฐ N"
europe = cases["Europe, EPSG:3857"]
fig, ax = plt.subplots(figsize=(7, 5))
europe.plot(ax=ax)
fig.canvas.draw()
print(f"figure 7.00 in wide, map axes {ax.get_window_extent().width / fig.dpi:.2f} in")
print("printed at 7 in wide:", scale_statement(europe.crs, ax))
for latitude in (40, 52, 64):
print(f" {scale_statement(europe.crs, ax, latitude=latitude)}")
figure 7.00 in wide, map axes 4.07 in
printed at 7 in wide: 1:82,208,353 in map units
1:63,043,193 at 40ยฐ N
1:50,694,683 at 52ยฐ N
1:36,113,969 at 64ยฐ N
The first version of this function had two faults, both fixed above. It took the figure's width as the map's, and on this map the equal-aspect axes use only part of the figure, so every fraction came out too small by that ratio. And it labelled the result with the latitude it was given while measuring true distance along the map's bottom edge. The three lines show why the latitude has to be real: one map, one width, and the fraction changes by the Mercator factor between them.
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 the ratio table above: 1.15 at 30ยฐ, 1.41 at 45ยฐ, 1.99 at 60ยฐ โ 1/cos(latitude) on a sphere, a little less on the ellipsoid. 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 about 1/cos(latitude): measured, 1.15 at 30ยฐ, 1.41 at 45ยฐ, 1.99 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".