How to Build a Reusable Map Style Module
On this page
Problem statement
The tenth map in a project looks different from the first. The boundary is 0.5 pt on one and 0.4 pt on another, the legend moved, the grey is #666 here and #64748b there, and one figure was drawn at 8 inches while the rest were drawn at 90 mm.
None of these are mistakes exactly. They are the accumulated cost of styling decisions living in the scripts that use them rather than in one place. The symptoms are recognisable:
- a change to the house palette means editing fourteen files
- two figures in the same report have different type sizes after placement
- nobody can say which colour ramp is "the" ramp
- the tested, accessible palette exists in a notebook somebody has closed
A style module fixes this the way any other shared code does: one definition, imported everywhere, versioned with the project. The module below is written to disk, imported, audited and used to draw a real map: population density in every local authority district of England and Wales, from the 2021 Census.
Quick answer
One module, three things in it: tokens, rcParams and helpers.
%%writefile mapstyle.py
import contextlib
import matplotlib.pyplot as plt
__version__ = "1.4.0"
MM = 1 / 25.4
COLOURS = { # measured contrast on white, from audit_palette()
"ink": "#1e293b", # 14.63:1 โ text
"indigo": "#1a3a6b", # 11.28:1 โ emphasis
"muted": "#64748b", # 4.76:1 โ context lines, small text
"faint": "#94a3b8", # 2.56:1 โ decorative only
"rule": "#e2e8f0", # 1.23:1 โ hairlines
"highlight": "#ef4444", # 3.76:1 โ graphics, never text
}
RAMPS = {"sequential": "YlGnBu", "diverging": "RdBu", "qualitative": "Dark2"}
SIZES = {"title": 9, "label": 7, "legend": 6.5, "footer": 5.5}
WIDTHS = {"coastline": 0.6, "boundary": 0.35, "hairline": 0.25}
RC = {
"font.size": SIZES["label"], "font.family": "sans-serif",
"axes.linewidth": 0.5, "pdf.fonttype": 42, "ps.fonttype": 42, "svg.fonttype": "none",
"figure.dpi": 150, "savefig.facecolor": "white", "savefig.bbox": None,
}
@contextlib.contextmanager
def house_style(**overrides):
with plt.rc_context({**RC, **overrides}):
yield
def figure(width_mm=170, aspect=0.72, **kwargs):
"""A figure at its final printed size. Place at 100%, never rescale."""
return plt.subplots(figsize=(width_mm * MM, width_mm * aspect * MM), **kwargs)
Writing mapstyle.py
import mapstyle
with mapstyle.house_style():
fig, ax = mapstyle.figure(width_mm=90)
print("inside the context:", plt.rcParams["font.size"], "pt text,",
(fig.get_size_inches() / mapstyle.MM).round(1), "mm")
plt.close(fig)
print("after it: ", plt.rcParams["font.size"], "pt text")
inside the context: 7.0 pt text, [90. 64.8] mm
after it: 10.0 pt text
The comments carrying the measured contrast ratios are not decoration. They are what stops somebody reaching for #94a3b8 for text โ and every one of them is checked by the audit below.
Tokens, rcParams, constructors and a tested palette
1. Separate tokens from usage
A token is a named value: COLOURS["muted"], SIZES["label"]. A usage is where it is applied: the coastline is drawn in muted at WIDTHS["coastline"].
Keeping them apart means the palette can change without touching the drawing code, and the drawing code documents which token each element uses. It also makes the accessibility audit a loop over one dictionary.
2. Put the rcParams in the module and apply them explicitly
Global plt.rcParams mutation from an imported module is a side effect that surprises people. The module exports a dictionary and a context manager, which is better still because it is scoped: as the quick answer printed, the house 7 pt text applied inside the with block and the default 10 pt came back after it. A script that imports mapstyle for its colours alone leaves every other figure untouched.
3. Provide the figure constructors, not just the values
The most valuable function in a style module is the one that creates a correctly sized figure, because that is the decision that goes wrong most often and costs most. Save a figure from the constructor and one drawn at 8 inches, and measure what a layout program will be given:
def pdf_size_mm(path):
x0, y0, x1, y1 = map(float, re.search(rb"/MediaBox \[\s*([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+)", Path(path).read_bytes()).groups())
return round((x1 - x0) / 72 * 25.4, 1), round((y1 - y0) / 72 * 25.4, 1)
with mapstyle.house_style():
for label, (fig, ax) in (("figure(width_mm=90)", mapstyle.figure(width_mm=90)),
("figsize=(8, 5.76)", plt.subplots(figsize=(8, 5.76)))):
ax.set_title("Population density", fontsize=8)
fig.savefig("out/size_test.pdf")
width = pdf_size_mm("out/size_test.pdf")[0]
print(f"{label:21} {width:6.1f} mm wide; in a 90 mm column 8 pt type prints at {8 * 90 / width:.2f} pt")
plt.close(fig)
figure(width_mm=90) 90.0 mm wide; in a 90 mm column 8 pt type prints at 8.00 pt
figsize=(8, 5.76) 203.2 mm wide; in a 90 mm column 8 pt type prints at 3.54 pt
An 8-inch figure placed in a 90 mm column is scaled by 0.443, and its 8 pt title prints at 3.54 pt. A constructor that takes millimetres makes that mistake hard to make. It is also why RC sets savefig.bbox to None: bbox_inches="tight" trims the canvas to the drawn content, so the saved file is no longer the size you asked for:
with mapstyle.house_style():
fig, ax = mapstyle.figure(width_mm=90)
ax.set_axis_off()
ax.text(0.5, 0.5, "90 mm wide?", ha="center")
for bbox in (None, "tight"):
fig.savefig("out/size_test.pdf", bbox_inches=bbox)
print(f"bbox_inches={bbox!s:6} -> {pdf_size_mm('out/size_test.pdf')} mm")
plt.close(fig)
bbox_inches=None -> (90.0, 64.8) mm
bbox_inches=tight -> (74.8, 55.0) mm
4. Include the tested palette and the test
The module should carry both the palette and the function that validates it, so a change to the palette can be checked in the same commit. The thresholds are WCAG's: 4.5:1 for normal text, 3:1 for graphical objects.
def audit_palette(colours, expected, background="white", text_threshold=4.5, graphic_threshold=3.0):
def luminance(colour):
c = np.asarray(mcolors.to_rgb(colour), dtype=float)
lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
return float(0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2])
failures = []
for name, colour in colours.items():
a, b = luminance(colour), luminance(background)
ratio = (max(a, b) + 0.05) / (min(a, b) + 0.05)
role = "text" if ratio >= text_threshold else "graphic" if ratio >= graphic_threshold else "decorative"
flag = "" if expected.get(name, role) == role else f" ! expected {expected[name]}"
failures += [name] if flag else []
print(f"{name:10} {colour} {ratio:6.2f} {role}{flag}")
return failures
first_draft = {"ink": "text", "indigo": "text", "muted": "text", "faint": "graphic",
"rule": "decorative", "highlight": "graphic"}
print("failures:", audit_palette(mapstyle.COLOURS, first_draft))
ink #1e293b 14.63 text
indigo #1a3a6b 11.28 text
muted #64748b 4.76 text
faint #94a3b8 2.56 decorative ! expected graphic
rule #e2e8f0 1.23 decorative
highlight #ef4444 3.76 graphic
failures: ['faint']
That turns "is this colour accessible?" from an argument into a test run โ and it caught this page's first draft, which listed faint as a graphic colour. At 2.56:1 it is below the 3:1 a boundary line or a legend swatch needs, so it is decorative only, and the module's expectations now say so.
5. Version it, and record the version on the output
A map made last March was made with last March's style. Recording the version in the footer means a difference between two figures can be explained rather than investigated:
def provenance(fig, sources, projection, made_on=None):
return fig.text(0.01, 0.008,
f"Source: {' ยท '.join(sources)} ยท Projection: {projection} ยท "
f"Made {made_on or date.today().isoformat()} ยท style {mapstyle.__version__}",
fontsize=mapstyle.SIZES["footer"], color=mapstyle.COLOURS["muted"], va="bottom", ha="left")
fig, ax = mapstyle.figure()
print(provenance(fig, ["ONS Census 2021"], "EPSG:27700", made_on="2026-09-30").get_text())
plt.close(fig)
Source: ONS Census 2021 ยท Projection: EPSG:27700 ยท Made 2026-09-30 ยท style 1.4.0
6. Keep it small
A style module that grows into a plotting framework stops being read. Tokens, rcParams, a figure constructor, an export function, and two or three helpers is enough for almost every project.
Anything that only one map needs belongs in that map's script.
A style module for England and Wales maps
The data used on this page
Local authority district boundaries (December 2024, ultra generalised) for England and Wales from the ONS Open Geography Portal, and usual residents from the 2021 Census (TS001) through Nomis for the April 2023 districts, both under the Open Government Licence. Areas come from the ultra-generalised boundaries, so the densities are close but not exact.
import io
import re
import subprocess
import sys
from datetime import date
from pathlib import Path
import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
Path("out").mkdir(exist_ok=True)
Path("mapstyle.py").unlink(missing_ok=True) # the page writes it from scratch
if not Path("data/districts.gpkg").exists():
lad = gpd.read_file(io.BytesIO(requests.get(
"https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Local_Authority_Districts_December_2024_Boundaries_UK_BUC/FeatureServer/0/query",
params={"where": "LAD24CD LIKE 'E%' OR LAD24CD LIKE 'W%'", "outFields": "LAD24CD,LAD24NM",
"outSR": 27700, "f": "geojson"}, timeout=120).content))
pop = pd.read_csv(io.StringIO(requests.get(
"https://www.nomisweb.co.uk/api/v01/dataset/NM_2021_1.data.csv", timeout=120,
params={"date": "latest", "geography": "TYPE424", "c2021_restype_3": 0, "measures": 20100,
"select": "geography_code,obs_value"}).text))
lad["residents"] = lad["LAD24CD"].map(pop.set_index("GEOGRAPHY_CODE")["OBS_VALUE"])
lad.to_file("data/districts.gpkg")
districts = gpd.read_file("data/districts.gpkg")
districts["density"] = districts["residents"] / (districts.area / 1e6)
print(len(districts), "districts;", int(districts["residents"].isna().sum()), "without a census count;",
f"{districts['residents'].sum():,.0f} residents")
318 districts; 0 without a census count; 59,597,542 residents
Example 1 โ the complete module
%%writefile mapstyle.py
"""mapstyle.py โ the house style for this project's maps.
Import the tokens; apply RC through the context manager; build figures with
`figure()` so every map is created at its final printed size.
"""
import contextlib
import os
from datetime import date
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import numpy as np
__version__ = "1.4.0"
MM = 1 / 25.4
COLOURS = {
"ink": "#1e293b", "indigo": "#1a3a6b", "muted": "#64748b",
"faint": "#94a3b8", "rule": "#e2e8f0", "highlight": "#ef4444",
"missing": "#e2e8f0", "land": "#f8fafc",
}
ROLES = {"ink": "text", "indigo": "text", "muted": "text", "faint": "decorative",
"rule": "decorative", "highlight": "graphic", "missing": "decorative", "land": "decorative"}
RAMPS = {"sequential": "YlGnBu", "diverging": "RdBu", "qualitative": "Dark2"}
SIZES = {"title": 9, "label": 7, "legend": 6.5, "footer": 5.5}
WIDTHS = {"coastline": 0.6, "boundary": 0.35, "hairline": 0.25, "highlight": 1.1}
RC = {
"font.size": SIZES["label"], "font.family": "sans-serif",
"axes.linewidth": 0.5, "axes.edgecolor": COLOURS["rule"],
"pdf.fonttype": 42, "ps.fonttype": 42, "svg.fonttype": "none",
"figure.dpi": 150, "savefig.facecolor": "white", "savefig.bbox": None,
"legend.frameon": False, "legend.fontsize": SIZES["legend"],
}
LAYER = {
"subject": dict(edgecolor="white", linewidth=WIDTHS["hairline"], zorder=5),
"outline": dict(facecolor="none", edgecolor=COLOURS["muted"], linewidth=WIDTHS["coastline"], zorder=6),
}
@contextlib.contextmanager
def house_style(**overrides):
with plt.rc_context({**RC, **overrides}):
yield
def figure(width_mm=170, aspect=0.72, **kwargs):
fig, ax = plt.subplots(figsize=(width_mm * MM, width_mm * aspect * MM), **kwargs)
if hasattr(ax, "set_axis_off"):
ax.set_aspect("equal")
ax.set_axis_off()
return fig, ax
def provenance(fig, sources, projection, made_on=None):
return fig.text(0.01, 0.008,
f"Source: {' ยท '.join(sources)} ยท Projection: {projection} ยท "
f"Made {made_on or date.today().isoformat()} ยท style {__version__}",
fontsize=SIZES["footer"], color=COLOURS["muted"], va="bottom", ha="left")
def export(fig, stem, preset="print_pdf"):
presets = {"print_pdf": dict(format="pdf", dpi=300), "print_png": dict(format="png", dpi=300),
"web_png": dict(format="png", dpi=144), "editable_svg": dict(format="svg")}
options = dict(presets[preset])
path = f"{stem}.{options['format']}"
with house_style():
fig.savefig(path, **options)
return path
def audit_palette(background="white", text_threshold=4.5, graphic_threshold=3.0):
"""Contrast of every token on the background, checked against its declared role."""
def luminance(colour):
c = np.asarray(mcolors.to_rgb(colour), dtype=float)
lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
return float(0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2])
rank = {"decorative": 0, "graphic": 1, "text": 2}
failures = []
for name, colour in COLOURS.items():
a, b = luminance(colour), luminance(background)
ratio = (max(a, b) + 0.05) / (min(a, b) + 0.05)
earned = "text" if ratio >= text_threshold else "graphic" if ratio >= graphic_threshold else "decorative"
if rank[ROLES[name]] > rank[earned]:
failures.append(f"{name} {colour} is {ratio:.2f}:1, declared {ROLES[name]}")
return failures
Overwriting mapstyle.py
ROLES declares what each token may be used for, and the audit fails only when a token is declared for more than its contrast earns โ a decorative colour that happens to pass as text is fine.
Example 2 โ the palette audit, run as a test
Path("tests").mkdir(exist_ok=True)
Path("tests/test_mapstyle.py").write_text('''import mapstyle
def test_palette_roles():
assert mapstyle.audit_palette() == []
def test_no_rcparams_changed_on_import():
import importlib, matplotlib
before = dict(matplotlib.rcParams)
importlib.reload(mapstyle)
assert dict(matplotlib.rcParams) == before
''')
result = subprocess.run([sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "tests"],
capture_output=True, text=True)
print(re.sub(r" in [\d.]+s", "", result.stdout.strip().splitlines()[-1]))
2 passed
A palette change that breaks a role now fails the build, and so does anyone who "helpfully" makes the module set rcParams on import.
Example 3 โ using it
import importlib
import mapstyle as ms
importlib.reload(ms) # pick up the complete module written above
with ms.house_style():
fig, ax = ms.figure(width_mm=170, aspect=1.1)
districts.plot(ax=ax, column="density", cmap=ms.RAMPS["sequential"], scheme="quantiles", k=6,
legend=True, legend_kwds={"loc": "upper left", "title": "residents per kmยฒ", "fmt": "{:,.0f}"},
missing_kwds={"color": ms.COLOURS["missing"], "label": "no data"}, **ms.LAYER["subject"])
gpd.GeoSeries([districts.union_all()], crs=districts.crs).plot(ax=ax, **ms.LAYER["outline"])
ax.set_title("Population density by local authority district, Census 2021",
fontsize=ms.SIZES["title"], loc="left", color=ms.COLOURS["ink"])
ms.provenance(fig, ["ONS Census 2021 (TS001)", "ONS LAD boundaries Dec 2024"],
"British National Grid (EPSG:27700)", made_on="2026-09-30")
path = ms.export(fig, "out/density_2021")
ms.export(fig, "out/density_2021", preset="web_png")
plt.close(fig)
print(path, pdf_size_mm(path), "mm |", f"{Path(path).stat().st_size / 1024:,.0f} kB")
out/density_2021.pdf (170.0, 187.0) mm | 193 kB
Twelve lines of drawing code, no styling decisions, and the next map in the report is identical by construction.
Why a module beats a stylesheet
Why a module beats a stylesheet
Matplotlib style sheets (.mplstyle) cover rcParams, which is the easy half. They cannot carry a palette dictionary, a figure constructor that takes millimetres, layer styles, or the audit function.
Use both: the rcParams in the module can be written out as a stylesheet for interactive use, while the module carries everything a stylesheet cannot express:
Path("out/house.mplstyle").write_text("\n".join(f"{k}: {str(v).lstrip('#') if isinstance(v, str) else v}"
for k, v in ms.RC.items()))
with plt.style.context("out/house.mplstyle"):
print("from the stylesheet:", plt.rcParams["font.size"], plt.rcParams["pdf.fonttype"], plt.rcParams["axes.edgecolor"])
from the stylesheet: 7.0 42 #e2e8f0
A stylesheet writes a colour without its # โ axes.edgecolor: e2e8f0 โ because # starts a comment there.
Why the figure constructor matters more than the colours
Colour drift is visible and gets fixed. Size drift is invisible until the figures are placed, and then every point-based size in the figure is wrong at once โ an 8-inch figure in a 90 mm column scales by 0.443.
A constructor that takes the width in millimetres makes the correct thing the easy thing, which is the only reliable way to enforce a convention.
Why the contrast ratios belong in comments
A palette is a set of hex codes, which look interchangeable. The measured ratios โ 4.76:1 for muted, 2.56:1 for faint, 1.23:1 for rule โ are what tell the next person which ones can carry text and which are decorative.
Putting them in a comment beside each token, and asserting them in audit_palette(), means the constraint survives a redesign.
Why versioning the style is worth one line
Figures outlive the code that made them. A footer that records the style version turns "why does this figure look different?" into a diff between two versions rather than an investigation.
It costs one string in the provenance line, next to the source and the projection that should be there anyway.
Import side effects, CI audits and fonts
- Do not mutate
plt.rcParamsat import. Export the dictionary or provide a context manager. savefig.bbox: Noneis deliberate โ"tight"changes the figure's physical size, as step 3 measured.- Keep the module importable without side effects, so it can be tested.
- Run the palette audit in CI. A palette change that breaks a role should fail the build.
- Fonts are an environment dependency.
"sans-serif"resolves to whatever the machine has โ DejaVu Sans on the machine that ran this page, Arial or Helvetica elsewhere. Pin the family and check it exists, or figures differ between machines. - A style module is not a plotting framework. When it grows a
plot_everything()function, split it. - Ship it with the project, not as a personal package that others cannot install.
- Record the version on the output, or two figures cannot be compared later.
Internal links
- How to build a print-ready map layout in Matplotlib โ the layout the module constructs
- How to make a map series with consistent symbology โ the module's main consumer
- How to export a map at print quality โ the export presets
- Accessible maps explained: contrast, text and alternatives โ the ratios in the comments
- How to choose and test a colour ramp in Python โ the tested ramp
- Visual hierarchy explained: what a map reader sees first โ the layer tiers
- Reproducible GIS workflows in Python โ versioning the outputs
- How to generate map images in batch โ many maps, one style
FAQ
Should I use a matplotlib style sheet or a module?
Both. A .mplstyle covers rcParams; a module carries the palette, layer styles, the figure constructor and the audit function that a stylesheet cannot express.
What belongs in a map style module?
Colour tokens with their measured contrast, ramp names, type sizes, line widths, rcParams, a figure constructor that takes millimetres, an export function and a provenance helper.
Why should the figure constructor take millimetres?
Because that is the unit the destination uses. Drawing at 8 inches and placing at 90 mm scales every size by 0.443, turning 8 pt type into 3.54 pt.
Should the module change plt.rcParams when imported?
No. Import-time side effects surprise people. Export a dictionary, or provide a context manager so the settings are scoped.
How do I stop somebody using an inaccessible colour for text?
Record the measured contrast ratio next to each token and assert the roles in an audit function that runs in CI.
Should I version the style module?
Yes, and record the version in the figure's footer. Figures outlive the code, and a version string turns a puzzling difference into a diff.