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.

Two panels listing what is shared across a map series and what may vary.
Per-panel quantiles are the most common way a series becomes incomparable.

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.

Decision diagram of three extent policies for a map series.
Each answers a different question, and mixing them silently answers none.

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.

Two panels contrasting a legend built per panel with one built from a shared style.
Empty classes are information: they say nothing in this panel reached that level.

Edge cases or notes

  • scheme="UserDefined" with classification_kwds={"bins": bins} is how you apply shared breaks in GeoPandas.
  • missing_kwds ensures 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.

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.