How to Make a Map Series with Consistent Symbology
Problem statement
A report needs one map per region, or one per year, or one per scenario. The obvious loop produces maps that cannot be compared:
for region, subset in gdf.groupby("region"):
subset.plot(column="rate", cmap="YlGnBu", legend=True) # wrong
plt.savefig(f"{region}.png")
Every panel gets its own colour scale, computed from its own data. A region whose rates run from 2% to 4% and a region whose rates run from 2% to 40% both get the full ramp, so the darkest colour means something different on every map. Readers compare the colours anyway, because that is what colours are for.
The same happens with class breaks, legend order, extent, aspect ratio, label density and the position of the apparatus. A map series is defined by everything that stays the same.
Quick answer
Compute the shared decisions once, before the loop, and pass them in:
import mapclassify
def series_style(gdf, column, k=5, scheme="Quantiles"):
"""Everything that must be identical across the panels."""
classifier = getattr(mapclassify, scheme)(gdf[column].dropna(), k=k)
return {
"bins": list(classifier.bins),
"vmin": float(gdf[column].min()),
"vmax": float(gdf[column].max()),
"cmap": "YlGnBu",
"categories": sorted(gdf["category"].dropna().unique()),
"figsize": (170 / 25.4, 120 / 25.4),
}
style = series_style(all_regions, "rate") # once, on the whole dataset
for region, subset in all_regions.groupby("region"):
plot_panel(subset, style, title=region) # many times, identically
The classifier is fitted to all the data, not to each panel. That single change is what makes the panels comparable.
Step-by-step solution
1. List what must be shared
For a comparable series, all of these are computed once:
| Property | Why it must be shared |
|---|---|
| class breaks | the colour must mean the same value on every panel |
| colour ramp and direction | otherwise dark means high here and low there |
vmin / vmax for continuous data |
the same reason |
| category order and colours | a legend that reorders is unreadable |
| figure size and aspect ratio | panels of different shapes cannot be compared |
| type sizes and line widths | otherwise one panel looks more important |
| legend content and position | the reader should not have to re-find it |
Extent is the interesting exception: a series of regions usually needs each panel zoomed to its own region, while a series of years usually needs one fixed extent.
2. Fit the classifier on the whole dataset
mapclassify fits breaks to whatever you give it. Give it everything:
import mapclassify
classifier = mapclassify.Quantiles(all_data[column].dropna(), k=5)
bins = classifier.bins
Then apply the same bins to each panel with scheme="UserDefined". Quantiles fitted per panel are the most common way a series becomes misleading, precisely because quantiles are the most common default.
3. Decide the extent policy deliberately
Three defensible policies, and they answer different questions:
- Shared extent โ every panel shows the same window. Right for a time series; makes spatial change obvious.
- Per-panel extent, shared scale โ each panel is centred on its own region but at the same map scale, so a bigger region simply extends beyond the frame or leaves more white space. Right for comparing regions of similar size.
- Per-panel extent, per-panel scale โ each region fills its frame. Right when the shapes matter more than the sizes, and it must be said in the caption because readers assume a shared scale.
4. Build one panel function and call it in a loop
The loop should contain no styling decisions. Everything is in the style dictionary or in the function.
This is also what makes the series maintainable: a change to the legend is one edit, not one edit per panel.
5. Handle the empty and the extreme panels
Two cases that break a naive loop:
- A panel with no data in some classes. The legend must still show every class, or the reader will think the class does not exist.
- A panel whose values are all in one class. It will be a single flat colour, which is correct and looks broken. A note in the panel โ "all districts in class 3" โ prevents the misreading.
6. Render the legend once if the panels share it
For a grid of small multiples in one figure, one legend serves all panels and saves the space that repeating it would consume. For separate files, each needs its own, because they will be seen apart.
Code examples
Example 1 โ the shared style object
from dataclasses import dataclass, field
import mapclassify
import matplotlib.pyplot as plt
MM = 1 / 25.4
@dataclass
class SeriesStyle:
column: str
bins: list
cmap: str = "YlGnBu"
vmin: float | None = None
vmax: float | None = None
figsize: tuple = (90 * MM, 90 * MM * 0.8)
fontsize: float = 7
boundary_width: float = 0.35
missing_colour: str = "#e2e8f0"
extent: tuple | None = None # None = per-panel extent
labels: list = field(default_factory=list)
@classmethod
def fit(cls, gdf, column, k=5, scheme="Quantiles", **kwargs):
values = gdf[column].dropna()
classifier = getattr(mapclassify, scheme)(values, k=k)
bins = list(classifier.bins)
labels = []
lower = values.min()
for upper in bins:
labels.append(f"{lower:,.1f} โ {upper:,.1f}")
lower = upper
return cls(column=column, bins=bins, vmin=float(values.min()),
vmax=float(values.max()), labels=labels, **kwargs)
def plot_panel(gdf, style: SeriesStyle, title=None, ax=None, show_legend=True):
fig = None
if ax is None:
fig, ax = plt.subplots(figsize=style.figsize)
gdf.plot(column=style.column, cmap=style.cmap, ax=ax,
scheme="UserDefined", classification_kwds={"bins": style.bins},
edgecolor="white", linewidth=style.boundary_width,
legend=show_legend,
legend_kwds={"fontsize": style.fontsize - 0.5, "frameon": False,
"loc": "lower left", "labels": style.labels},
missing_kwds={"color": style.missing_colour, "label": "no data"})
if style.extent:
ax.set_xlim(style.extent[0], style.extent[2])
ax.set_ylim(style.extent[1], style.extent[3])
ax.set_aspect("equal")
ax.set_axis_off()
if title:
ax.set_title(title, fontsize=style.fontsize + 1, loc="left")
return fig, ax
Example 2 โ small multiples in one figure with one shared legend
import math
import matplotlib.pyplot as plt
def small_multiples(gdf, style, facet_col, ncols=4, share_extent=True):
facets = sorted(gdf[facet_col].dropna().unique())
nrows = math.ceil(len(facets) / ncols)
fig, axes = plt.subplots(nrows, ncols,
figsize=(style.figsize[0] * ncols,
style.figsize[1] * nrows))
axes = axes.ravel() if hasattr(axes, "ravel") else [axes]
extent = tuple(gdf.total_bounds) if share_extent else None
for ax, facet in zip(axes, facets):
subset = gdf[gdf[facet_col] == facet]
panel_style = style.__class__(**{**style.__dict__, "extent": extent})
plot_panel(subset, panel_style, title=str(facet), ax=ax, show_legend=False)
if len(subset) and subset[style.column].nunique() == 1:
ax.text(0.5, 0.02, "all areas in one class", transform=ax.transAxes,
ha="center", fontsize=style.fontsize - 1.5, color="#64748b")
for ax in axes[len(facets):]:
ax.set_visible(False)
handles, labels = build_class_handles(style)
fig.legend(handles, labels, loc="lower center", ncols=len(labels),
frameon=False, fontsize=style.fontsize - 0.5,
bbox_to_anchor=(0.5, -0.01))
return fig, axes
def build_class_handles(style):
import matplotlib.patches as mpatches
cmap = plt.get_cmap(style.cmap, len(style.bins))
handles = [mpatches.Patch(facecolor=cmap(i), edgecolor="white", linewidth=0.5)
for i in range(len(style.bins))]
return handles, style.labels
Example 3 โ verifying the series is actually consistent
def check_series(paths_or_figs, style):
"""Catch the panels that drifted."""
import matplotlib.pyplot as plt
problems = []
sizes, extents = set(), set()
for i, fig in enumerate(paths_or_figs):
sizes.add(tuple(round(v, 3) for v in fig.get_size_inches()))
for ax in fig.axes:
if ax.get_images() or ax.collections:
extents.add((round(ax.get_xlim()[1] - ax.get_xlim()[0], 1),
round(ax.get_ylim()[1] - ax.get_ylim()[0], 1)))
for text in fig.findobj(plt.Text):
if text.get_text().strip() and text.get_fontsize() != style.fontsize:
if abs(text.get_fontsize() - style.fontsize) > 1.5:
problems.append(f"panel {i}: text at {text.get_fontsize()} pt")
if len(sizes) > 1:
problems.append(f"{len(sizes)} different figure sizes: {sizes}")
if len(extents) > 1 and style.extent is not None:
problems.append(f"{len(extents)} different extents despite a shared extent")
print(f"{len(problems)} consistency problem(s)")
for p in problems[:10]:
print(" !", p)
return problems
Explanation
Why per-panel classification is the central error
Quantiles, natural breaks and equal intervals are all fitted to the data they are given. Fitted per panel, they guarantee that each map uses the full ramp โ which is exactly what makes the panels incomparable, because "dark" now means the top fifth of that panel.
Readers do not know this and would not expect it. A series of choropleths is read as a single map cut into pieces, and the colour is assumed to be the shared language.
The fix is one line โ fit on the whole dataset, apply UserDefined breaks per panel โ and it is the difference between a series and a collection.
Why the extent policy has to be explicit
A series of years with a shared extent shows change; a series of regions with per-panel extents shows each region. Both are correct, and mixing them silently is not.
The case that misleads is per-panel extent with per-panel scale, where a small region is enlarged to fill the frame. It looks like the other panels, and readers assume the scale is shared. That series needs the scale stated on each panel or in the caption.
Why the legend must show empty classes
A panel where no area falls in the top class will, by default, produce a legend without that class. The reader then compares a four-class panel with a five-class one and concludes the classification changed.
Building the legend from the shared style rather than from the panel's data keeps every class visible, with the empty ones simply unused.
Why a style object beats a function with many arguments
A series accumulates shared decisions: breaks, ramp, sizes, widths, the missing-data colour, the legend labels. Passed as keyword arguments, they drift โ one call gets a different linewidth and nobody notices until the panels are side by side.
A single object, constructed once and passed down, makes drift impossible for anything it covers, and makes the shared decisions reviewable in one place.
Edge cases or notes
scheme="UserDefined"withclassification_kwds={"bins": bins}is how you apply shared breaks in GeoPandas.missing_kwdsensures no-data areas are drawn and labelled rather than left blank.- Diverging series need a shared, symmetric norm anchored at the midpoint, not per-panel limits.
- Categorical series need a shared category order, or the legend reorders between panels.
- Outliers in one panel widen the shared scale for everybody. Consider a top class of "and over".
- Label placement cannot be shared โ it is computed in pixels per panel.
- Render one panel first and check it before looping over sixty.
- File names should sort in the order the panels should be read.
Internal links
- Choropleth classification explained โ the class breaks being shared
- How to build a reusable map style module โ where the style object lives
- How to generate map images in batch โ running the loop unattended
- Colour on maps explained: sequential, diverging and qualitative โ choosing the shared ramp
- How to build a print-ready map layout in Matplotlib โ the panel layout
- How to make a choropleth map in GeoPandas โ the single-panel case
- Fixing choropleth colours that look wrong โ when the classification is the problem
- How to label map features without overlaps in Matplotlib โ per-panel labelling
FAQ
Why do my map panels use different colour scales?
Because the classifier was fitted per panel. Fit it once on the whole dataset and apply the same breaks to every panel with scheme="UserDefined".
Should every panel show the same extent?
For a time series, yes. For a series of regions, per-panel extents are usual โ but keep the scale shared, or state in the caption that it is not.
How do I keep the legend the same across panels?
Build it from the shared style object rather than from each panel's data, so classes with no members in a panel still appear.
What if one panel's values are all in one class?
It will be a flat colour, which is correct and looks like an error. Add a short note in the panel saying so.
How do I stop the panels drifting?
Put every shared decision in one style object constructed before the loop, and check the rendered panels for differing figure sizes, extents and type sizes.
Can the panels share one legend?
In a small-multiples figure, yes โ one legend for the whole figure saves the space that repetition would consume. Separate files each need their own.