Building a Choropleth Colour Ramp with geopandas and branca

Part of the Symbology & Data-Driven Styling guide.

Operative rule: compute the break list exactly once and pass that same list to both the branca colormap and the Folium style_function — any arrangement where two lists exist will eventually disagree, and the disagreement is invisible in code review.

How the Ramp Fits Together

A Folium choropleth is three cooperating pieces: a classification (break values), a colour mapping (which colour a class gets) and a style callback (what Folium asks for each feature). branca — Folium’s colour dependency — provides StepColormap, which takes a list of colours and a list of index values and returns a callable. That callable is what turns a number into a hex string.

The subtle part is the shape of the arguments. StepColormap(colors, index=…) expects len(index) == len(colors) + 1, because the index holds the edges of the classes. Passing an equal-length list is the single most common mistake, and it fails quietly: the top class disappears and every feature in it renders in the class below.

Edges and colours in a StepColormap Five colours need six edges: the lowest edge, four internal boundaries and the highest edge. Supplying five edges for five colours silently collapses the top class, so every feature above the last boundary renders in the previous colour. k colours need k+1 edges — the classic off-by-one correct — 5 colours, 6 edges 0 2 5 10 20 50 wrong — 5 colours, 5 edges the darkest class never renders — features above the last edge silently fall into the one below

Everything else follows from getting the ramp object right. Because the same breaks drive the legend, this pattern is what makes the symbology and data-driven styling discipline enforceable rather than aspirational.

Production-Ready Implementation

from __future__ import annotations

import branca.colormap as cm
import folium
import geopandas as gpd
import numpy as np

NO_DATA = "#E6E6E6"


def compute_breaks(values: np.ndarray, k: int = 5) -> list[float]:
    """Quantile edges, rounded to values a human would choose."""
    clean = values[~np.isnan(values)]
    if clean.size == 0:
        raise ValueError("no finite values to classify")
    raw = np.percentile(clean, np.linspace(0, 100, k + 1))
    # Round to 1 significant figure above the magnitude so labels read well.
    return [float(round(v, 1)) for v in raw]


def build_choropleth(
    gdf: gpd.GeoDataFrame,
    field: str,
    colors: list[str],
    breaks: list[float] | None = None,
) -> tuple[folium.Map, cm.StepColormap]:
    """Return a Folium map plus the colormap used, so callers can reuse it."""
    if gdf.crs is None or gdf.crs.to_epsg() != 4326:
        gdf = gdf.to_crs(epsg=4326)          # Folium expects lon/lat degrees

    edges = breaks or compute_breaks(gdf[field].to_numpy(dtype="float64"),
                                     k=len(colors))
    if len(edges) != len(colors) + 1:
        raise ValueError(
            f"{len(colors)} colours need {len(colors) + 1} edges, got {len(edges)}"
        )

    ramp = cm.StepColormap(colors=colors, index=edges,
                           vmin=edges[0], vmax=edges[-1],
                           caption=field.replace("_", " "))

    def style(feature: dict) -> dict:
        value = feature["properties"].get(field)
        if value is None:
            fill = NO_DATA                    # never let None reach the ramp
        else:
            fill = ramp(min(max(float(value), edges[0]), edges[-1]))
        return {"fillColor": fill, "color": "#FFFFFF",
                "weight": 0.6, "fillOpacity": 0.85}

    centre = gdf.geometry.union_all().centroid
    m = folium.Map(location=[centre.y, centre.x], zoom_start=6,
                   tiles="cartodbpositron")
    folium.GeoJson(gdf, name=field, style_function=style,
                   tooltip=folium.GeoJsonTooltip(fields=[field])).add_to(m)
    ramp.add_to(m)                            # legend from the SAME object
    return m, ramp


if __name__ == "__main__":
    regions = gpd.read_file("regions.geojson")
    PALETTE = ["#F1EEF6", "#BDC9E1", "#74A9CF", "#2B8CBE", "#045A8D"]
    fmap, colormap = build_choropleth(regions, "incidents_per_1k", PALETTE,
                                      breaks=[0.0, 2.0, 5.0, 10.0, 20.0, 50.0])
    fmap.save("choropleth.html")

Note the clamp inside style. branca raises for values outside [vmin, vmax], and in an automated pipeline the day will come when a value exceeds the frozen top edge. Clamping keeps the map rendering and puts the outlier in the top class, which is almost always the right visual answer; if you would rather know about it, count the clamped features and log the count as part of the run record described in Pipeline Observability & Alerting.

Choosing Between Frozen and Recomputed Breaks

Frozen versus recomputed classification breaks Frozen breaks keep two rebuilds comparable and keep the legend stable, at the risk of drifting away from the data's real range over time. Recomputed breaks always fit the current data and always fill the legend, at the cost that yesterday's map and today's map use different scales. Two policies — decide once, in configuration, not per run frozen breaks two rebuilds are directly comparable the legend never changes under a reader outliers clamp into the top class risk: drifts away from the real range over a year recomputed each run always fits the current distribution every class is always populated no clamping ever needed risk: yesterday's map used a different scale

For an operational dashboard, freeze. For an exploratory notebook, recompute. If you must recompute in production, publish the break list next to the map so a reader comparing two dates can at least see that the scale moved.

What Each Object Owns

Three objects touch colour in this pipeline and it is worth being explicit about which one is authoritative, because bugs here come from two of them holding their own copy of the truth.

Which object owns which decision The break list owns the class boundaries and is the single authority. The branca colormap owns the value-to-colour mapping and the legend rendering, both derived from the break list. The Folium style function owns null handling, clamping and stroke styling, and delegates colour entirely to the colormap. One authority, two consumers break list owns: class boundaries lives in: configuration changes: deliberately the only place a number is decided StepColormap owns: value → colour owns: the rendered legend derived from: the break list never constructed with its own numbers style_function owns: null handling owns: clamping and strokes delegates: colour, entirely a hex literal here is always a bug

Verification Steps

  • Assert len(edges) == len(colors) + 1 in the builder, as the code above does — this catches the off-by-one at build time rather than in review.
  • Print the count of features per class and confirm no class is empty and none holds more than about half the dataset.
  • Render a feature with a null value and confirm it comes out in the no-data colour.
  • Confirm the legend’s outer labels equal the first and last edge exactly.
  • Rebuild with the next day’s data and diff the edge list; with frozen breaks it must be byte-identical.

Choosing the Palette Itself

The five hex values in PALETTE deserve as much thought as the breaks. Three properties make a sequential palette work on a map rather than merely look pleasant in isolation.

It must be monotonic in lightness. Readers rank classes by how dark they are, not by hue, so each step has to be perceptibly darker than the one before it. A palette that dips in lightness halfway through produces a map where the middle class looks like an outlier.

It must survive the basemap. A pale first class disappears entirely over a light basemap, which turns “lowest class” into “no data” in the reader’s eye. If the lightest colour is close to the basemap’s surface, either darken it or give every polygon a thin stroke so its boundary remains visible regardless of fill.

And it must survive colour-vision deficiency. Single-hue sequential ramps — light blue to dark blue, light amber to dark brown — are safe by construction, because they carry their information in lightness. Multi-hue ramps can be safe too, but they have to be checked rather than assumed, and the check is worth doing once per palette rather than once per project.

Finally, keep the palette in the same configuration object as the breaks. A palette living in one module and breaks living in another is the same two-sources-of-truth problem that produces mismatched legends, one level up. When both travel together the whole symbology decision can be reviewed, diffed and rolled back as a unit.

Common Errors & Fixes

ValueError: Provided values are outside the range of the colormap

A value exceeded vmax. Either clamp before calling the ramp, as shown above, or widen the top edge. Do not silently drop the feature — a missing polygon reads as “no data” when it actually means “unusually high”.

The darkest colour never appears on the map

The edge list and colour list are the same length, so the top class was collapsed. Count both lists and fix the edges.

The legend appears but has no title

caption was not set on the StepColormap. An unlabelled legend forces readers to guess the units, which is exactly the ambiguity a legend exists to remove.

All polygons render grey

Every value is arriving as None — usually because the attribute name changed case during an export, or the join that produced it silently failed. Print gdf[field].isna().mean() before styling and abort the build if it exceeds a threshold you are comfortable with.