Source code: Make a map in Python with matplotlib and cartopy: projections, classified choropleths, scale bars, north arrows and inset maps
Runnable Python scripts, requirements and console output for the tutorial “Make a map in Python with matplotlib and cartopy: projections, classified…
Last updated
These are the exact scripts used to produce every figure and number in Make a map in Python with matplotlib and cartopy: projections, classified choropleths, scale bars, north arrows and inset maps. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- python_maps_cartopy.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython python_maps_cartopy.pypython_maps_cartopy.py
"""Make publication-quality maps in Python with matplotlib and cartopy.
Natural Earth for world and context layers, ONS local authority boundaries with Census 2021 population densityfor a classified choropleth of England and Wales, and the Copernicus DEM for a shaded-relief map of GreaterManchester. Covers projections, classification and legends, scale bars, north arrows, inset locator maps,gridlines, light and dark styling and print export.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/python-maps-matplotlib-cartopy/"""import ioimport osimport sysimport warningsfrom importlib.metadata import versionfrom pathlib import Path
for var in ("PROJ_LIB", "PROJ_DATA", "GDAL_DATA"): # stray installs (e.g. PostGIS) break the wheels' own copies os.environ.pop(var, None)
import numpy as npimport pandas as pdimport geopandas as gpdimport requestsimport rasterioimport rasterio.mergeimport rasterio.featuresfrom rasterio.warp import reproject, Resampling, calculate_default_transformimport pystac_clientimport planetary_computer as pcimport mapclassifyimport cartopyimport cartopy.crs as ccrsimport cartopy.feature as cfeaturefrom cartopy.io import shapereaderimport matplotlibmatplotlib.use("Agg") # render to files onlyimport matplotlib.pyplot as pltimport matplotlib.patheffects as pefrom matplotlib.colors import LightSource, ListedColormap, Normalizefrom matplotlib.patches import Patch, Rectanglefrom matplotlib_scalebar.scalebar import ScaleBarfrom shapely.geometry import box, Polygonfrom pyproj import Geodfrom PIL import Image
warnings.filterwarnings("ignore", category=UserWarning)warnings.filterwarnings("ignore", category=cartopy.io.DownloadWarning)DATA = Path("data"); DATA.mkdir(exist_ok=True)OUT = Path("output"); OUT.mkdir(exist_ok=True)cartopy.config["data_dir"] = str(Path("cache") / "cartopy") # Natural Earth downloads land here
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 densitySTAC_URL = "https://planetarycomputer.microsoft.com/api/stac/v1"OSGB = ccrs.OSGB(approx=False) # British National Grid, EPSG:27700MM = 1 / 25.4 # millimetres to inches
# Two themes: the same map must read on a white page and on a dark screen.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
def section(title): print(f"\n=== {title} ===")
section("versions")print(f"python {sys.version.split()[0]} | cartopy {cartopy.__version__} | matplotlib {matplotlib.__version__} | " f"geopandas {gpd.__version__} | mapclassify {mapclassify.__version__} | " f"matplotlib-scalebar {version('matplotlib-scalebar')} | rasterio {rasterio.__version__}")
def ne(scale, category, name): return gpd.read_file(shapereader.natural_earth(resolution=scale, category=category, name=name))
# ------------------------------------------------------------------ Step 1: projectionssection("Step 1: Natural Earth and four projections")land110 = ne("110m", "physical", "land")print(f"Natural Earth 110m land: {len(land110)} polygons, CRS {land110.crs.to_string()}")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")plt.close(fig)
# Distortion in numbers: the area of a 1 degree x 1 degree cell at different latitudes in each projectionrows = []for lat in (0, 30, 53.5, 70): cell = gpd.GeoSeries([Polygon([(0, lat), (1, lat), (1, lat + 1), (0, lat + 1)])], crs=4326) true_area = cell.to_crs("+proj=laea +lat_0=%s +lon_0=0.5" % (lat + 0.5)).area.iloc[0] row = {"lat": lat, "true_km2": true_area / 1e6} # PlateCarree's native units are degrees, so measure it as the same projection in metres (+proj=eqc) for name, crs in [("PlateCarree", "+proj=eqc +R=6371000"), ("Mercator", projs[1][1].proj4_init), ("Equal Earth", projs[2][1].proj4_init)]: row[name] = cell.to_crs(crs).area.iloc[0] / true_area rows.append(row)print("area of a 1 x 1 degree cell in the projection / true area:")print(pd.DataFrame(rows).round(3).to_string(index=False))
# ------------------------------------------------------------------ Step 2: world choroplethsection("Step 2: world choropleth of population density (Natural Earth 50m, Equal Earth)")world = ne("50m", "cultural", "admin_0_countries")world = world[world.POP_EST > 0].copy()world["area_km2"] = world.to_crs("+proj=eqearth").area / 1e6world["density"] = world.POP_EST / world.area_km2print(f"{len(world)} countries with a population estimate; POP_YEAR values: {sorted(world.POP_YEAR.unique())[-3:]}")q = mapclassify.Quantiles(world.density, k=6)print("quantile breaks (people per km2):", np.round(q.bins, 1).tolist())print(world.nlargest(3, "density")[["NAME", "density"]].round(0).to_string(index=False))print("United Kingdom:", round(float(world.loc[world.ISO_A3 == "GBR", "density"].iloc[0]), 1), "per km2")
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.ybfor 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))ax.gridlines(color="#8a8a8a", lw=0.3, alpha=0.6)ax.set_title("Population density by country, quantile classes (Natural Earth 1:50m, Equal Earth projection)", fontsize=8.5)fig.savefig(OUT / "02-world-population-density.png", dpi=200, facecolor="white")plt.close(fig)
# ------------------------------------------------------------------ Step 3: ONS boundaries + censussection("Step 3: England and Wales local authorities and Census 2021 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")print(f"{len(lad)} local authority districts; {lad.density.isna().sum()} without a census value")print(f"density: median {lad.density.median():,.0f}, min {lad.density.min():,.1f} " f"({lad.loc[lad.density.idxmin(), 'LAD22NM']}), max {lad.density.max():,.0f} " f"({lad.loc[lad.density.idxmax(), 'LAD22NM']}) usual residents per km2")
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])}print("class breaks and counts:")for name, c in schemes.items(): print(f" {name:<35} breaks {np.round(c.bins).astype(int).tolist()} counts {c.counts.tolist()} " f"GADF {c.gadf:.3f}")
fig, axs = plt.subplots(1, 3, figsize=(170 * MM, 82 * MM), subplot_kw={"projection": OSGB}, layout="constrained")for ax, name in zip(axs, list(schemes)[:3]): c = schemes[name] cc = SEQ(np.linspace(0, 1, c.k)) ax.set_extent([130_000, 660_000, 0, 660_000], crs=OSGB) for k in range(c.k): ax.add_geometries(lad.geometry[c.yb == k], crs=OSGB, facecolor=cc[k], edgecolor="white", lw=0.1) lows = np.r_[lad.density.min(), c.bins[:-1]] ax.legend(handles=[Patch(fc=col, label=f"{lo:,.0f}–{hi:,.0f} ({n})") for col, lo, hi, n in zip(cc, lows, c.bins, c.counts)], loc="upper left", fontsize=5.2, frameon=False, handlelength=1, handletextpad=0.4, borderaxespad=0.2) ax.set_title(name, fontsize=7.5) ax.axis("off")fig.savefig(OUT / "03-classification-schemes.png", dpi=200, facecolor="white")plt.close(fig)
# ------------------------------------------------------------------ Step 4: the publication mapdef 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
section("Step 4: a publication-quality map of England and Wales")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)print(f"grid convergence at the north arrow: true north is {angle:+.2f} degrees from grid north")for ext, p in paths.items(): extra = "" if ext == "png": with Image.open(p) as im: extra = f", {im.width} x {im.height} px" print(f" {p.name}: {p.stat().st_size / 1024:,.0f} KB{extra}")_, dark_paths = ew_map("dark", "06-england-wales-density-map-dark", dpi=200)
# Scale check: what the scale bar assumes vs the true ground distance at different eastingsfor e in (100_000, 400_000, 650_000): lon1, lat1 = ccrs.PlateCarree().transform_point(e, 300_000, OSGB) lon2, lat2 = ccrs.PlateCarree().transform_point(e + 100_000, 300_000, OSGB) d = Geod(ellps="WGS84").inv(lon1, lat1, lon2, lat2)[2] print(f" 100 km of grid at easting {e:,} m = {d / 1000:.2f} km on the ground")
# ------------------------------------------------------------------ Step 5: raster themesection("Step 5: shaded relief of Greater Manchester (Copernicus DEM GLO-30)")gm = lad[lad.LAD22CD.str.match(r"E0800000\d|E08000010")].copy()print(f"Greater Manchester districts: {len(gm)}")xmin, ymin, xmax, ymax = gm.total_bounds + np.array([-6000, -6000, 6000, 6000])ll = gpd.GeoSeries([box(xmin, ymin, xmax, ymax)], crs=27700).to_crs(4326).total_boundsdem_path = DATA / "gm_dem_bng_30m.tif"if not dem_path.exists(): cat = pystac_client.Client.open(STAC_URL, modifier=pc.sign_inplace) items = list(cat.search(collections=["cop-dem-glo-30"], bbox=list(ll)).items()) print("DEM tiles:", sorted(i.id for i in 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 for s in srcs: s.close() 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) with rasterio.open(dem_path, "w", driver="GTiff", width=width, height=height, count=1, dtype="float32", crs="EPSG:27700", transform=dst_t, nodata=np.nan, compress="deflate") as dst: dst.write(out, 1)with rasterio.open(dem_path) as src: dem = src.read(1) dext = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]print(f"DEM {dem.shape[1]} x {dem.shape[0]} px at 30 m; elevation {np.nanmin(dem):.0f} to {np.nanmax(dem):.0f} m")inside_gm = rasterio.features.geometry_mask(gm.geometry, dem.shape, rasterio.transform.from_origin(dext[0], dext[3], 30, 30), invert=True)print(f"inside Greater Manchester: {np.nanmin(dem[inside_gm]):.0f} to {np.nanmax(dem[inside_gm]):.0f} m, " f"mean {np.nanmean(dem[inside_gm]):.0f} m")
t = THEMES["light"]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")])gl = ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=True, color="#555555", lw=0.3, alpha=0.6, xlocs=np.arange(-3, -1.5, 0.2), ylocs=np.arange(53.2, 53.9, 0.1), x_inline=False, y_inline=False)gl.top_labels = gl.right_labels = Falsegl.xlabel_style = gl.ylabel_style = {"size": 6.5}ax.add_artist(ScaleBar(1, "m", location="lower right", length_fraction=0.2, box_alpha=0.75, font_properties={"size": 7}))north_arrow(ax, 0.94, 0.86, t, size=0.07)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); cb.ax.tick_params(labelsize=6.5)ins = fig.add_axes([0.855, 0.74, 0.13, 0.2], projection=OSGB)ins.set_extent([-30_000, 680_000, -20_000, 1_230_000], crs=OSGB)ins.add_geometries(countries10[countries10.ADMIN.isin(["United Kingdom", "Ireland", "Isle of Man"])].geometry, crs=ccrs.PlateCarree(), facecolor="#d9d9d4", edgecolor="#8a8a8a", lw=0.2)ins.add_geometries([box(dext[0], dext[2], dext[1], dext[3])], crs=OSGB, facecolor="#d53e4f", edgecolor="#d53e4f", lw=0.8)ins.set_facecolor("#e8eef2")fig.text(0.04, 0.975, "Greater Manchester: terrain and the ten metropolitan boroughs", fontsize=10, weight="bold", va="top")ax.text(0.01, 0.99, "Copernicus DEM GLO-30 © DLR, Airbus (ESA, EU); ONS LAD Dec 2022, OGL v3.0, " "contains OS data © Crown copyright 2022", transform=ax.transAxes, fontsize=5, color="#333333", va="top", bbox=dict(fc="white", ec="none", alpha=0.7, pad=1))fig.savefig(OUT / "05-greater-manchester-terrain.png", dpi=200, facecolor="white")plt.close(fig)print("saved figures:", sorted(p.name for p in OUT.glob("0*.png")))