Make a map in Python with matplotlib and cartopy: projections, classified choropleths, scale bars, north arrows and inset maps

A cartopy tutorial for publication-quality maps in Python: choose a projection, draw a classified choropleth of Census 2021 population density with a proper legend, add a scale bar, a true-north arrow, an inset locator map and gridlines, style it for light and dark backgrounds, export at 300 dpi to PNG, PDF and SVG, and shade the Copernicus DEM for Greater Manchester.

Level
Beginner
Tested with
Python 3.12
Source code
Download scripts

Key takeaways

  1. Choose the projection for the job. A 1° × 1° cell at 53.5° N, Manchester's latitude, is drawn 2.88 times too large in Mercator and 1.69 times too large in Plate Carrée, but at its true size in Equal Earth, so use an equal-area projection for world choropleths and a national grid such as British National Grid for local maps.
  2. The classification is the map. For 331 districts of England and Wales, equal intervals put 260 in the lowest class; quantiles put 55 in each; Fisher-Jenks scored the best goodness of variance fit (0.80). Rounded, roughly doubling manual breaks (100, 250, 500 … 5,000 per km²) are easiest to read.
  3. Draw polygons on cartopy axes with ax.add_geometries(..., crs=...), one call per class, and set zorder explicitly; otherwise context layers can hide the choropleth.
  4. A scale bar is only honest where the scale is constant. In British National Grid, 100 km of grid was 99.94 to 100.05 km on the ground across England and Wales, so one bar is fine; on a Mercator or Plate Carrée world map it is not.
  5. Size the figure in millimetres and export at print resolution. A 120 × 140 mm figure at 300 dpi is 1,417 × 1,653 px (554 KB as PNG); vector PDF and SVG versions were 3.0 and 8.8 MB because every district outline is kept.

Before you start

  • Basic Python and some GeoPandas; the GeoPandas getting-started tutorial covers what you need
  • Python 3.10+ with cartopy, matplotlib, matplotlib-scalebar, mapclassify, geopandas, rasterio, pystac-client, planetary-computer, pandas, pillow and requests
  • An internet connection: Natural Earth, ONS boundaries, Nomis census data and the Copernicus DEM are downloaded on the first run, with no account or key

Short answer: to make a map in Python, create a matplotlib axes with a cartopy projection, ax = plt.axes(projection=ccrs.OSGB()) for Britain or ccrs.EqualEarth() for the world, set the area with ax.set_extent(...), and draw your data with ax.add_geometries(gdf.geometry, crs=...) for polygons or ax.imshow(array, extent=..., transform=...) for rasters. Classify the values with mapclassify and build the legend from the class breaks. Add ax.gridlines(draw_labels=True), a scale bar from matplotlib-scalebar, a north arrow with ax.annotate, and an inset locator map with fig.add_axes(..., projection=...). Size the figure in millimetres and save it with fig.savefig("map.png", dpi=300) or as PDF or SVG for print.

Interactive web maps are great for exploring, but reports, papers and slides still need a static map that is correct, legible and reproducible. matplotlib with cartopy is the standard way to make one in Python: it handles projections properly, works with GeoPandas and rasterio, and exports to any format a publisher wants. This tutorial builds up to a finished, print-ready map of England and Wales and a shaded-relief map of Greater Manchester, with every map drawn from real data:

Map Data Projection
Four projections with Tissot’s indicatrices Natural Earth 1:110m land Plate Carrée, Mercator, Equal Earth, Orthographic
World population density Natural Earth 1:50m countries Equal Earth
Population density, England and Wales ONS districts and Census 2021 (Nomis) British National Grid
Greater Manchester terrain Copernicus DEM GLO-30 British National Grid

You will learn how to:

  1. pick a projection and see what it does to areas;
  2. draw a choropleth with a classified legend, and choose between classification schemes;
  3. add the parts of a proper map: gridlines and labels, a scale bar, a north arrow, an inset locator map, a title and a source note;
  4. export at print resolution to PNG, PDF and SVG;
  5. restyle the same map for light and dark backgrounds;
  6. draw a raster with hillshading and a colour bar.

All outputs below are real results from python_maps_cartopy.py, run on 23 October 2026 with Python 3.12.0, cartopy 0.26.0, matplotlib 3.11.2, geopandas 1.2.0, mapclassify 2.11.0 and matplotlib-scalebar 0.9.0.

Install the packages

Terminal window
pip install cartopy matplotlib matplotlib-scalebar mapclassify geopandas rasterio pystac-client planetary-computer pandas pillow requests

Current cartopy releases ship binary wheels for Windows, macOS and Linux, so pip does not need GEOS or PROJ installed separately. The pinned versions are in requirements.txt.

Step 1: choose a projection

Every flat map distorts the globe. Cartopy’s crs module has dozens of projections, and the right choice depends on what the map must preserve. To see the difference, the script draws Natural Earth’s 1:110m land in four projections, with identical 500 km circles (Tissot’s indicatrices) on the ground.

python_maps_cartopy.py
import cartopy
import cartopy.crs as ccrs
import cartopy.feature as cfeature
from cartopy.io import shapereader
cartopy.config["data_dir"] = str(Path("cache") / "cartopy") # Natural Earth downloads land here
def ne(scale, category, name):
return gpd.read_file(shapereader.natural_earth(resolution=scale, category=category, name=name))
land110 = ne("110m", "physical", "land")
projs = [("PlateCarree (equirectangular)", ccrs.PlateCarree()),
("Mercator", ccrs.Mercator(min_latitude=-80, max_latitude=84)),
("Equal Earth (equal area)", ccrs.EqualEarth()),
("Orthographic over the UK", ccrs.Orthographic(central_longitude=-2, central_latitude=54))]
fig = plt.figure(figsize=(170 * MM, 112 * MM), layout="constrained")
for i, (name, proj) in enumerate(projs):
ax = fig.add_subplot(2, 2, i + 1, projection=proj)
ax.set_global()
ax.add_feature(cfeature.OCEAN.with_scale("110m"), facecolor="#e8eef2")
ax.add_geometries(land110.geometry, crs=ccrs.PlateCarree(), facecolor="#d9d9d4", edgecolor="#8a8a8a", lw=0.3)
# Tissot indicatrices: identical 500 km circles on the ground, drawn in each projection
ax.tissot(rad_km=500, lons=np.arange(-150, 180, 60), lats=np.arange(-60, 80, 30), n_samples=40,
facecolor="#d53e4f", alpha=0.6, edgecolor="none")
ax.gridlines(color="#8a8a8a", lw=0.3, alpha=0.6)
ax.set_title(name, fontsize=8.5)
fig.savefig(OUT / "01-map-projections-tissot.png", dpi=200, facecolor="white")

The key idea in cartopy is that the axes have a projection (how the map is drawn) and each dataset has a CRS (what its coordinates mean). You pass the data’s CRS with crs= or transform=, and cartopy reprojects it. Natural Earth is in longitude and latitude, so its CRS is ccrs.PlateCarree(). If coordinate reference systems are new to you, coordinate reference systems in Python explains EPSG codes, to_crs and why degrees are not metres.

Four world maps in a two-by-two grid with red circles of identical ground size every 60 degrees of longitude and 30 degrees of latitude. Plate Carrée: a rectangle with circles stretched east–west at high latitudes. Mercator: circles stay round but grow rapidly towards the poles, and Greenland looks as large as Africa. Equal Earth: an oval map where circles keep the same area but are squashed near the edges. Orthographic: a globe centred on the UK with circles shrinking towards the limb.
The same 500 km circles in four projections. Mercator keeps shapes but inflates areas; Equal Earth keeps areas but bends shapes; Orthographic shows the Earth as a globe seen from space.

The script also measures the distortion: the area of a 1° × 1° cell, as drawn, divided by its true area.

Output — area distortion
area of a 1 x 1 degree cell in the projection / true area:
lat true_km2 PlateCarree Mercator Equal Earth
0.0 12308.310 1.005 1.000 1.0
30.0 10642.104 1.162 1.345 1.0
53.5 7298.450 1.694 2.882 1.0
70.0 4157.854 2.974 8.924 1.0

At Manchester’s latitude, Mercator draws areas 2.9 times too large; at 70° N, 8.9 times. That is fine for web map tiles and navigation, where shapes and bearings matter, but wrong for any map that compares areas, such as a choropleth. A rough guide:

Map Good choices in cartopy
World thematic map EqualEarth, Robinson, Mollweide
Continent LambertAzimuthalEqualArea, AlbersEqualArea, LambertConformal
Great Britain OSGB (British National Grid, EPSG:27700)
Any national grid ccrs.epsg(code) or ccrs.Projection(pyproj.CRS(code))
A globe view Orthographic, NearsidePerspective

Step 2: a world choropleth with a classified legend

A choropleth shades areas by a value. Two rules make one honest: map a rate or density, not a raw count, and classify the values into a few classes with a legend that states the breaks. Natural Earth’s countries include a population estimate (POP_EST); we divide it by the area measured in an equal-area projection and split the result into six quantile classes with mapclassify.

python_maps_cartopy.py
world = ne("50m", "cultural", "admin_0_countries")
world = world[world.POP_EST > 0].copy()
world["area_km2"] = world.to_crs("+proj=eqearth").area / 1e6
world["density"] = world.POP_EST / world.area_km2
q = mapclassify.Quantiles(world.density, k=6)
fig = plt.figure(figsize=(170 * MM, 95 * MM), layout="constrained")
ax = fig.add_subplot(projection=ccrs.EqualEarth())
ax.set_global()
ax.set_facecolor("#e8eef2")
cols = SEQ(np.linspace(0, 1, q.k))
world["cls"] = q.yb
for k in range(q.k):
ax.add_geometries(world.geometry[world.cls == k], crs=ccrs.PlateCarree(), facecolor=cols[k],
edgecolor="white", lw=0.2)
lows = np.r_[world.density.min(), q.bins[:-1]]
labels = [f"{lo:,.0f} – {hi:,.0f} ({n})" for lo, hi, n in zip(lows, q.bins, q.counts)]
ax.legend(handles=[Patch(fc=c, ec="none", label=l) for c, l in zip(cols, labels)], loc="lower left",
title="People per km² (countries)", fontsize=6.5, title_fontsize=7, frameon=True, framealpha=0.85,
facecolor="white", edgecolor="none", bbox_to_anchor=(-0.01, 0.0))
Output — world population density
240 countries with a population estimate; POP_YEAR values: [np.int32(2018), np.int32(2019), np.int32(2020)]
quantile breaks (people per km2): [18.9, 51.0, 88.4, 151.9, 349.7, 28236.2]
NAME density
Macao 28236.0
Singapore 11814.0
Maldives 8030.0
United Kingdom: 277.7 per km2
World map in the Equal Earth projection with countries in six quantile classes of population density from pale yellow-green (0 to 19 people per km²) to deep red (350 to 28,236). India, Bangladesh, Japan, South Korea, the Philippines, the Netherlands, Belgium, the UK and Nigeria are in the top classes; Canada, Russia, Australia, Mongolia and much of Africa's Sahara and South America are in the lowest. A legend at the lower left lists each class with 40 countries.
Population density by country in quantile classes: 40 countries per class. The legend shows the break values and the count in each class.

The legend is built by hand from Patch objects rather than from a colour bar, because the classes are discrete: each patch shows exactly one colour, its range and how many countries fall in it. The colours are the warm half of the Spectral palette, a sequential scheme from light to dark, which suits a value that runs from low to high. Use a diverging scheme only when the data have a meaningful midpoint, such as change above and below zero.

Step 3: download districts and census data, and compare classifications

For the main map we use the 331 local authority districts of England and Wales from the ONS (December 2022, generalised to 20 m), joined to Census 2021 population density (table TS006) from the Nomis API.

python_maps_cartopy.py
ONS = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Local_Authority_Districts_December_2022_UK_BGC_V2/FeatureServer/0/query")
NOMIS = "https://www.nomisweb.co.uk/api/v01/dataset/NM_2026_1.data.csv" # Census 2021 TS006 population density
lad_path = DATA / "lad_dec2022_ew_bgc.gpkg"
if not lad_path.exists():
r = requests.get(ONS, params={"where": "LAD22CD LIKE 'E%' OR LAD22CD LIKE 'W%'", "outFields": "LAD22CD,LAD22NM",
"outSR": 27700, "f": "geojson", "resultRecordCount": 1000}, timeout=300)
r.raise_for_status()
gpd.GeoDataFrame.from_features(r.json()["features"], crs=27700).to_file(lad_path)
lad = gpd.read_file(lad_path)
r = requests.get(NOMIS, params={"date": "latest", "geography": "TYPE154", "measures": 20100,
"select": "geography_code,obs_value"}, timeout=300)
r.raise_for_status()
dens = pd.read_csv(io.StringIO(r.text)).rename(columns={"GEOGRAPHY_CODE": "LAD22CD", "OBS_VALUE": "density"})
lad = lad.merge(dens, on="LAD22CD", how="left")
schemes = {"Equal interval": mapclassify.EqualInterval(lad.density, k=6),
"Quantiles": mapclassify.Quantiles(lad.density, k=6),
"Fisher-Jenks (natural breaks)": mapclassify.FisherJenks(lad.density, k=6),
"Manual (rounded, roughly doubling)": mapclassify.UserDefined(lad.density,
bins=[100, 250, 500, 1000, 2500, 5000])}
Output — districts and classifications
331 local authority districts; 0 without a census value
density: median 615, min 25.5 (Eden), max 15,703 (Tower Hamlets) usual residents per km2
class breaks and counts:
Equal interval breaks [2638, 5251, 7864, 10477, 13090, 15703] counts [260, 51, 7, 6, 4, 3] GADF 0.633
Quantiles breaks [158, 338, 615, 1513, 3315, 15703] counts [56, 55, 55, 55, 55, 55] GADF 0.663
Fisher-Jenks (natural breaks) breaks [789, 2005, 3798, 7173, 11839, 15703] counts [178, 62, 48, 29, 11, 3] GADF 0.796
Manual (rounded, roughly doubling) breaks [100, 250, 500, 1000, 2500, 5000, 15703] counts [25, 67, 48, 52, 62, 52, 25] GADF 0.720

Density ranges from 25.5 residents per km² in Eden (Cumbria) to 15,703 in Tower Hamlets, a factor of 600, and the distribution is strongly skewed. That makes the classification decisive:

Three small maps of England and Wales with the same density data in six classes. Equal interval: almost everything is pale, with only inner London in red. Quantiles: a varied map where the big cities, the M62 corridor and the south-east are red and orange, and rural areas pale. Fisher-Jenks: mostly pale with London, Birmingham, Manchester, Liverpool and a few towns in orange and red. Each map has a legend with the class ranges and counts.
One dataset, three classifications. Equal interval hides almost all variation outside London; quantiles show the most pattern but put 615 and 1,513 per km² in the same class; Fisher-Jenks minimises the variance within classes.
  • Equal interval splits the range into equal steps. With skewed data, 260 of 331 districts land in the lowest class and the map shows only London.
  • Quantiles put the same number of districts (55) in each class. The map is varied, but the breaks are irregular and hard to remember.
  • Fisher-Jenks (natural breaks) minimises variation within classes and had the best fit score, a GADF of 0.80.
  • Manual breaks at rounded, roughly doubling values (100, 250, 500, 1,000, 2,500, 5,000) suit skewed data, are easy to read and compare between maps, and still scored a respectable 0.72. We use them for the final map.

Step 4: build a publication-quality map

A finished map needs more than coloured polygons. The function below draws the England and Wales map with everything a reader expects, so we can call it once for each theme. It is shown in full because the order of the layers matters.

python_maps_cartopy.py
OSGB = ccrs.OSGB(approx=False) # British National Grid, EPSG:27700
MM = 1 / 25.4 # millimetres to inches
def north_arrow(ax, x, y, theme, size=0.06):
"""Arrow to true north at axes position (x, y): rotate by the grid convergence at that point."""
px, py = ax.transAxes.transform((x, y))
mx, my = ax.transData.inverted().transform((px, py))
lon, lat = ccrs.PlateCarree().transform_point(mx, my, ax.projection)
nx, ny = ax.projection.transform_point(lon, lat + 0.1, ccrs.PlateCarree())
angle = np.degrees(np.arctan2(nx - mx, ny - my)) # clockwise from grid north
dx, dy = size * np.sin(np.radians(angle)), size * np.cos(np.radians(angle))
ax.annotate("", xy=(x + dx, y + dy), xytext=(x, y), xycoords="axes fraction",
arrowprops=dict(arrowstyle="-|>,head_width=0.35,head_length=0.7", color=theme["text"], lw=1.2))
ax.text(x + dx * 1.35, y + dy * 1.35, "N", transform=ax.transAxes, ha="center", va="center",
fontsize=8, weight="bold", color=theme["text"])
return angle
def ew_map(theme_name, path_stem, dpi):
t = THEMES[theme_name]
c = schemes["Manual (rounded, roughly doubling)"]
cc = SEQ(np.linspace(0, 1, c.k + 1))[1:] if theme_name == "dark" else SEQ(np.linspace(0, 1, c.k + 1))[:-1]
fig = plt.figure(figsize=(120 * MM, 140 * MM), facecolor=t["bg"])
ax = fig.add_axes([0.075, 0.075, 0.905, 0.845], projection=OSGB)
ax.set_extent([60_000, 690_000, -10_000, 680_000], crs=OSGB)
ax.set_facecolor(t["sea"])
# zorder matters: context land first (1), then one collection per class on top (2)
ax.add_geometries(context.geometry, crs=ccrs.PlateCarree(), facecolor=t["land"], edgecolor=t["edge"], lw=0.3,
zorder=1)
for k in range(c.k):
ax.add_geometries(lad.geometry[c.yb == k], crs=OSGB, facecolor=cc[k], edgecolor=t["edge"], lw=0.15,
zorder=2)
gl = ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=True, xlocs=range(-8, 4, 2), ylocs=range(50, 57),
color=t["grid"], lw=0.3, alpha=0.7, dms=True, x_inline=False, y_inline=False)
gl.top_labels = gl.right_labels = False
gl.xlabel_style = gl.ylabel_style = {"size": 6.5, "color": t["muted"]}
lows = np.r_[0, c.bins[:-1]]
labels = [f"{lo:,.0f} – {hi:,.0f}" if i < c.k - 1 else f"{lo:,.0f} – {lad.density.max():,.0f}"
for i, (lo, hi) in enumerate(zip(lows, c.bins))]
labels = [f"{l} ({n})" for l, n in zip(labels, c.counts)]
leg = ax.legend(handles=[Patch(fc=col, ec=t["edge"], lw=0.3, label=l) for col, l in zip(cc[::-1], labels[::-1])],
loc="upper right", bbox_to_anchor=(0.995, 0.995), fontsize=6.5, title_fontsize=7,
title="Usual residents per km²\n(number of districts)", frameon=True, framealpha=0.92,
facecolor=t["bg"], edgecolor=t["frame"], labelcolor=t["text"])
leg.get_title().set_color(t["text"]); leg.get_frame().set_linewidth(0.4)
# 1 map unit = 1 m in British National Grid; fixed_value gives a round 100 km bar
ax.add_artist(ScaleBar(1, "m", location="lower left", fixed_value=100, fixed_units="km", box_alpha=0,
color=t["text"], font_properties={"size": 7}, scale_loc="top"))
angle = north_arrow(ax, 0.07, 0.17, t)
# Inset locator: where England and Wales sit in Europe
ins = fig.add_axes([0.09, 0.665, 0.24, 0.24],
projection=ccrs.LambertAzimuthalEqualArea(central_longitude=10, central_latitude=52))
ins.set_extent([-12, 32, 34, 68], crs=ccrs.PlateCarree())
ins.set_facecolor(t["sea"])
ins.add_geometries(europe.geometry, crs=ccrs.PlateCarree(), facecolor=t["land"], edgecolor=t["edge"], lw=0.2)
ins.add_geometries(ew_outline, crs=OSGB, facecolor="#d53e4f", edgecolor="none")
ins.add_geometries([box(60_000, -10_000, 690_000, 680_000)], crs=OSGB, facecolor="none",
edgecolor=t["text"], lw=0.8)
ins.spines["geo"].set_edgecolor(t["frame"]); ins.spines["geo"].set_linewidth(0.6)
ax.spines["geo"].set_edgecolor(t["frame"]); ax.spines["geo"].set_linewidth(0.6)
fig.text(0.02, 0.975, "Population density, England and Wales, Census 2021", fontsize=10, weight="bold",
color=t["text"], va="top")
fig.text(0.02, 0.945, "Local authority districts (331), British National Grid", fontsize=7.5,
color=t["muted"], va="top")
fig.text(0.98, 0.006, "Source: ONS Census 2021 (TS006), ONS LAD Dec 2022 BGC, OGL v3.0. Contains OS data "
"© Crown copyright and database right 2022.\nNatural Earth (public domain). Made with cartopy.",
ha="right", va="bottom", fontsize=4.8, color=t["muted"])
paths = {}
for ext in ("png", "pdf", "svg") if theme_name == "light" else ("png",):
p = OUT / f"{path_stem}.{ext}"
fig.savefig(p, dpi=dpi, facecolor=t["bg"])
paths[ext] = p
plt.close(fig)
return angle, paths
countries10 = ne("10m", "cultural", "admin_0_countries")
context = countries10[countries10.ADMIN.isin(["United Kingdom", "Ireland", "France", "Isle of Man", "Belgium",
"Netherlands", "Guernsey", "Jersey"])]
europe = ne("50m", "cultural", "admin_0_countries").cx[-30:45, 30:72]
ew_outline = [lad.union_all().simplify(2000)]
angle, paths = ew_map("light", "04-england-wales-density-map", dpi=300)
Choropleth map of population density in the 331 local authority districts of England and Wales from Census 2021, in seven classes from pale yellow-green (under 100 residents per km²) to deep red (5,000 to 15,703). London, Birmingham, Greater Manchester, Merseyside, Leeds, Tyneside, Bristol and the south coast cities are red or dark orange; most of Wales, Cumbria, Northumberland, North Yorkshire and Lincolnshire are pale. A legend at the top right lists the classes with district counts; a 100 km scale bar and a north arrow tilted slightly right are at the lower left; an inset map of Europe at the top left shows England and Wales in red; latitude and longitude labels run along the left and bottom edges; Scotland, Ireland and France are grey context.
The finished map: classified legend with counts, gridlines labelled in degrees, a 100 km scale bar, a north arrow pointing to true north, an inset locator, a title and a source note. Exported at 300 dpi for a 120 mm wide column.

Each element answers a reader’s question:

  • Extent and context. set_extent in British National Grid metres frames England and Wales; Natural Earth 1:10m countries in grey show Scotland, Ireland and France so the reader is not left with a floating shape. Set zorder explicitly: without it, the context layer can be drawn on top of the choropleth.
  • Gridlines. ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=True) draws lines of longitude and latitude, curved in this projection, and labels them on the left and bottom edges. x_inline=False, y_inline=False keeps labels outside the frame.
  • Legend. Ordered from high to low, with rounded breaks, the unit in the title and the number of districts in each class. The top class is labelled with the real maximum (15,703) rather than “5,000+”, so the reader knows the range.
  • Scale bar. matplotlib_scalebar.ScaleBar(1, "m") tells the bar that one data unit is one metre, which is true in British National Grid. fixed_value=100, fixed_units="km" gives a round 100 km.
  • North arrow. In British National Grid, grid north and true north coincide only along 2° W. The function finds true north at the arrow’s position by stepping 0.1° north and measuring the angle, then tilts the arrow by it.
  • Inset locator. A second cartopy axes in a different projection (Lambert azimuthal equal-area for Europe), with the main map’s extent drawn as a box in British National Grid.
  • Title, subtitle and source. Placed with fig.text in figure coordinates, so they do not move when the map’s extent changes. Crediting the data is a licence requirement, not decoration.
Output — north arrow and scale check
grid convergence at the north arrow: true north is +3.27 degrees from grid north
04-england-wales-density-map.png: 554 KB, 1417 x 1653 px
04-england-wales-density-map.pdf: 2,997 KB
04-england-wales-density-map.svg: 8,791 KB
100 km of grid at easting 100,000 m = 99.97 km on the ground
100 km of grid at easting 400,000 m = 100.05 km on the ground
100 km of grid at easting 650,000 m = 99.94 km on the ground

At the arrow’s position in west Cornwall’s longitude, true north is 3.3° clockwise from the top of the map. The scale check shows why a single scale bar is acceptable here: across the whole map, 100 km of grid is between 99.94 and 100.05 km on the ground. That is not true of a world map in Plate Carrée or Mercator, where the scale changes with latitude by a factor of two or more; leave the scale bar off those, or use a graticule instead.

Colour, contrast and dark backgrounds

The same function draws a dark version by swapping the theme dictionary: background, sea, land, text and grid colours all come from THEMES. For a dark background the colour ramp is shifted one step, so the lowest class is a pale yellow that stands out from the dark land rather than a mid-tone that merges with it.

python_maps_cartopy.py
THEMES = {
"light": dict(bg="white", sea="#e8eef2", land="#d9d9d4", edge="white", text="#1a1a1a", muted="#555555",
grid="#8a8a8a", frame="#333333", halo="white"),
"dark": dict(bg="#0d1012", sea="#161b1e", land="#2a3033", edge="#0d1012", text="#e7ebe9", muted="#a0aaa7",
grid="#6d7774", frame="#a0aaa7", halo="#0d1012"),
}
SEQ = ListedColormap(plt.get_cmap("Spectral_r")(np.linspace(0.42, 1.0, 256))) # yellow -> red: sequential
_, dark_paths = ew_map("dark", "06-england-wales-density-map-dark", dpi=200)
The same England and Wales population density map on a near-black background: dark grey land and sea, district boundaries as thin dark lines, classes from pale cream (under 100 per km²) to deep crimson (over 5,000), white text, legend, scale bar and north arrow, and a dark inset of Europe with England and Wales in red.
The dark theme for slides and dark-mode web pages. Only the theme dictionary changes; the data, classes and layout are identical.

A few rules keep colours legible for everyone: use a sequential ramp for ordered data and check it still reads in greyscale; avoid red–green pairs for the classes that matter most; and keep boundaries thin and in the background colour so small districts do not turn into lines.

Step 5: a raster map with hillshading, a colour bar and an inset

Maps of rasters need the same parts plus a colour bar. The script downloads the two Copernicus DEM GLO-30 tiles that cover Greater Manchester from the Planetary Computer, merges them, reprojects to British National Grid at 30 m and shades the relief with matplotlib’s LightSource.

python_maps_cartopy.py
cat = pystac_client.Client.open(STAC_URL, modifier=pc.sign_inplace)
items = list(cat.search(collections=["cop-dem-glo-30"], bbox=list(ll)).items())
srcs = [rasterio.open(i.assets["data"].href) for i in items]
mosaic, mt = rasterio.merge.merge(srcs, bounds=tuple(ll))
src_crs = srcs[0].crs
width = int((xmax - xmin) / 30); height = int((ymax - ymin) / 30)
dst_t = rasterio.transform.from_origin(xmin, ymax, 30, 30)
out = np.full((height, width), np.nan, dtype="float32")
reproject(mosaic[0].astype("float32"), out, src_transform=mt, src_crs=src_crs, dst_transform=dst_t,
dst_crs="EPSG:27700", resampling=Resampling.bilinear)
# ... the script writes this to data/gm_dem_bng_30m.tif and reads it back:
with rasterio.open(dem_path) as src:
dem = src.read(1)
dext = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
fig = plt.figure(figsize=(170 * MM, 130 * MM), facecolor="white")
ax = fig.add_axes([0.065, 0.05, 0.775, 0.86], projection=OSGB)
ax.set_extent(dext, crs=OSGB)
norm = Normalize(0, 600)
ls = LightSource(azdeg=315, altdeg=45)
rgb = ls.shade(np.nan_to_num(dem, nan=0), cmap=plt.get_cmap("Spectral_r"), norm=norm, vert_exag=2,
blend_mode="soft", dx=30, dy=30)
ax.imshow(rgb, extent=dext, transform=OSGB, origin="upper", interpolation="bilinear")
ax.add_geometries(gm.geometry, crs=OSGB, facecolor="none", edgecolor="#1a1a1a", lw=0.7)
for _, rw in gm.iterrows():
p = rw.geometry.representative_point()
ax.text(p.x, p.y, rw.LAD22NM, transform=OSGB, ha="center", va="center", fontsize=7, color="#111111",
path_effects=[pe.withStroke(linewidth=2.2, foreground="white")])
cax = fig.add_axes([0.86, 0.25, 0.02, 0.45])
cb = fig.colorbar(plt.cm.ScalarMappable(norm=norm, cmap="Spectral_r"), cax=cax, extend="max")
cb.set_label("Elevation (m)", fontsize=7.5)
Output — DEM
Greater Manchester districts: 10
DEM tiles: ['Copernicus_DSM_COG_10_N53_00_W002_00_DEM', 'Copernicus_DSM_COG_10_N53_00_W003_00_DEM']
DEM 2214 x 1729 px at 30 m; elevation -2 to 634 m
inside Greater Manchester: 7 to 539 m, mean 123 m
Shaded-relief map of Greater Manchester and its surroundings with elevation coloured from purple and blue (near sea level) through green and yellow to orange and dark red (600 m and above). The ten boroughs are outlined in black and labelled. The low Mersey valley and the Manchester plain in the south-west are blue; the West Pennine Moors north of Bolton and the South Pennines east of Rochdale, Oldham and Tameside rise to orange and red. A colour bar for 0 to 600 m, a north arrow, a scale bar, latitude and longitude labels and a small inset map of the British Isles with the area marked in red complete the map.
Greater Manchester on the Copernicus DEM at 30 m, hillshaded from the north-west. The boroughs rise from 7 m beside the Mersey to 539 m on the moors above Saddleworth.

LightSource.shade blends the colour-mapped elevation with a hillshade computed from the same array; dx and dy are the pixel size in metres, so the slopes are correct. The colour bar is drawn from a separate ScalarMappable with the same norm and colormap, because the image itself is already RGB. Labels get a white halo with path_effects so they stay readable on any colour. The Spectral palette suits elevation because it runs through many distinguishable hues; for a single variable such as density, the sequential half used above is the better choice.

This map uses the same ten boroughs as the Geospatial Python essentials series, where Part 3 polygonizes ESA WorldCover for Greater Manchester and shows how to publish rasters as Cloud Optimized GeoTIFFs.

Pitfalls and limitations

  • Pass the data’s CRS, every time. Forgetting crs= in add_geometries or transform= in imshow and plot is the commonest cartopy bug: the data are assumed to be in the axes’ projection and end up in the wrong place, or vanish.
  • GeoDataFrame.plot on cartopy axes. It works only if the data are already in the axes’ projection; it does not reproject. Reproject first with gdf.to_crs(ax.projection) or use add_geometries.
  • Scale bars on world maps. They are only valid where scale is nearly constant, as in a national grid. On Mercator or Plate Carrée maps, omit them.
  • Generalised boundaries and coastlines. We used ONS’s generalised (BGC) boundaries and Natural Earth’s 1:10m coastline; they do not match exactly at the coast and are not suitable for measuring areas. Use full-resolution boundaries for analysis and generalised ones for small-scale maps.
  • Vintages differ. Census 2021 densities are on 2021 local authority codes, joined to December 2022 boundaries; all 331 matched here, but boundary changes in later years can break joins. Natural Earth’s population estimates are from 2018 to 2020, and its country polygons follow its own policy on disputed borders.
  • The DEM is a surface model. The Copernicus DEM includes buildings and trees, so city centres look slightly raised; it is fine for a backdrop, but use a bare-earth model for hydrology.
  • Downloads on the first run. Cartopy fetches Natural Earth files on demand; the script stores them in cache/cartopy so later runs work offline.

Frequently asked questions

How do I make a map in Python?

Install cartopy and matplotlib, create an axes with a projection (plt.axes(projection=ccrs.EqualEarth())), add data with ax.add_geometries(gdf.geometry, crs=ccrs.PlateCarree()) or ax.imshow(..., transform=...), and save with plt.savefig("map.png", dpi=300). Add ax.coastlines() or Natural Earth features for context and ax.gridlines(draw_labels=True) for labelled coordinates. For quick checks, gdf.plot() from GeoPandas is enough; for interactive web maps, use folium.

What is cartopy used for?

Cartopy adds map projections and geographic data handling to matplotlib. It knows hundreds of coordinate reference systems, reprojects vectors and rasters on the fly, draws gridlines and labels, and downloads Natural Earth layers. It replaced Basemap, which is no longer maintained, as the standard Python library for static maps.

How do I add a scale bar and north arrow to a cartopy map?

For the scale bar, install matplotlib-scalebar and add ScaleBar(1, "m") to an axes whose projection is in metres, such as British National Grid or UTM. For a north arrow, use ax.annotate("", xy=..., xytext=..., arrowprops=...) with an “N” label; on a projected map, rotate it by the grid convergence, as in Step 4, so it points to true north.

How do I make a choropleth map with a proper legend in Python?

Classify the values with mapclassify (Quantiles, FisherJenks or UserDefined with your own breaks), draw each class with its own colour, and build the legend from matplotlib.patches.Patch objects labelled with the class ranges. GeoPandas can do this in one line with gdf.plot(column="density", scheme="FisherJenks", k=6, legend=True), but a hand-built legend gives you control over labels, order and rounding.

How do I save a matplotlib map for publication?

Set figsize to the printed size in inches (millimetres divided by 25.4), design the fonts in points for that size, and save with fig.savefig("map.png", dpi=300) for raster output or fig.savefig("map.pdf") for vector output. Use facecolor= to set the background, and check the result at 100% zoom before submitting.

Which projection should I use for a map of the UK?

British National Grid (ccrs.OSGB(approx=False), EPSG:27700) for Great Britain, and Irish Transverse Mercator (EPSG:2157) for Ireland and Northern Ireland together. Both are transverse Mercator projections with nearly constant scale across the country, so scale bars, distances and areas are reliable.

Next steps

Data and licences

  • Natural Earth 1:110m land and 1:50m and 1:10m admin-0 countries: public domain, naturalearthdata.com, downloaded through cartopy.
  • Local Authority Districts (December 2022) Boundaries UK BGC V2: Source: Office for National Statistics, licensed under the Open Government Licence v3.0. Contains OS data © Crown copyright and database right 2022. From the ONS Open Geography Portal, accessed on 23 October 2026.
  • Census 2021 TS006 (population density): Office for National Statistics via Nomis, Open Government Licence v3.0.
  • Copernicus DEM GLO-30: © DLR e.V. 2010–2014 and © Airbus Defence and Space GmbH 2014–2018, provided under COPERNICUS by the European Union and ESA; all rights reserved. Free to use under the Copernicus DEM licence. Accessed from the Microsoft Planetary Computer.
  • Code: python_maps_cartopy.py, tested with Python 3.12.0, cartopy 0.26.0, matplotlib 3.11.2, matplotlib-scalebar 0.9.0, mapclassify 2.11.0, geopandas 1.2.0 and rasterio 1.5.2.

Key terms

Cartopy
A Python package that adds map projections to matplotlib. Its GeoAxes know their coordinate reference system and reproject data on the fly when you pass transform= or crs=.
Choropleth
A thematic map that shades areas (countries, districts) by a value. Use rates or densities rather than raw counts, so large areas do not dominate.
Classification (class breaks)
Grouping a continuous value into a few classes for colouring. Common schemes are equal interval, quantiles, natural breaks (Fisher-Jenks) and manual breaks.
Goodness of variance fit (GVF / GADF)
A score from 0 to 1 of how well a classification groups similar values together. mapclassify reports the absolute-deviation version, GADF.
Grid convergence
The angle between grid north (up the map's y axis) and true north at a point. In British National Grid it is zero on the 2° W central meridian and grows towards the east and west.
Tissot's indicatrix
Identical small circles drawn on the ground and plotted in a projection; their changing size and shape show how the projection distorts area and angles.

Tutorial Python

Zonal statistics in Python: exactextract vs rasterstats, with partial pixels, land-cover fractions and a benchmark

Calculate zonal statistics in Python with exactextract and rasterstats on real data: ONS 2021 LSOA boundaries for Cumbria with GHSL population, the Copernicus DEM and ESA WorldCover. Covers partial-pixel weighting vs pixel centroids, CRS alignment, categorical land-cover fractions, a speed benchmark, GeoPackage export and choropleth maps.

Intermediate 14 min read

Tutorial Python Part 2 of 3

Spatial joins in GeoPandas: sjoin, predicates, sjoin_nearest and point in polygon

A practical guide to spatial joins in GeoPandas with real Greater Manchester data: 13,187 NaPTAN bus stops, 1,386 OpenStreetMap schools, 215 ONS wards and 1,702 LSOAs. Covers point in polygon with sjoin, predicates, counts per polygon, how='left'/'inner'/'right', sjoin_nearest with max_distance, clip and overlay, and a timing comparison of a naive loop against the spatial index.

Beginner 12 min read

Tutorial Python Part 1 of 3

Coordinate reference systems in Python: EPSG codes, pyproj transforms and reprojecting vectors and rasters

A practical guide to coordinate reference systems in Python with pyproj, GeoPandas and rasterio, tested on real Greater Manchester data. Covers EPSG codes, WGS 84 vs British National Grid (EPSG:27700), the always_xy axis-order trap, OSTN15 vs Helmert datum shifts checked against 13,187 NaPTAN bus stops, measured area and distance errors in the wrong CRS, and reprojecting a GeoDataFrame and a Copernicus DEM raster.

Beginner 13 min read