Compute the class breaks once, build one colormap from them, and derive both the feature styling and the legend from that single object — then caption it with the classification method, and give missing data an entry of its own.

Jump to heading Why this matters

A choropleth without a legend is not a simplified map; it is an unreadable one. The whole content of the visual is an encoding from a number to a colour, and without the key the reader knows only that one area is darker than another. Worse, they will guess — and the two guesses people make are that the scale is linear and that the palest colour means zero, both of which are usually wrong.

Folium makes the map easy and leaves the legend to you, which is why so many production dashboards ship a beautifully rendered choropleth with a hand-typed HTML box beside it listing ranges that were correct three sprints ago. The fix is structural rather than cosmetic: build one object that owns the breaks, and let both the map and the legend read from it.

Why a legend is not decoration on a choroplethA choropleth encodes a number as a colour, and without a legend the encoding is unreadable — the map shows that somewhere is darker than somewhere else and nothing about how much. Three decisions have to be visible for the map to be interpretable at all. The classification method decides which values share a colour, and quantiles, equal intervals and natural breaks produce visibly different maps from identical data. The class count decides how much resolution the reader gets, and beyond about seven classes most people cannot reliably match a patch to a legend entry. The treatment of missing data decides whether an area with no value reads as zero, which is the single most common misreading of a choropleth and the easiest to prevent with a distinct hatch and its own legend entry.THREE THINGS THE LEGEND HAS TO MAKE VISIBLEthe classification methodquantiles, equal intervals and natural breaks give visibly different maps from identical numbersthe class countpast about seven, readers stop being able to match a patch to an entry reliablyhow missing data is drawnan unstyled area reads as zero — give it a hatch and its own legend entryState the method in the legend title. A reader who knows the classes are quantiles interprets an even spread ofcolour correctly; one who assumes equal intervals reads the same map as a uniform distribution.

Jump to heading Prerequisites

  • folium>=0.15 and branca>=0.7, which supplies the colormap objects Folium’s legends are built from.
  • mapclassify>=2.6 if you want natural breaks or quantiles computed properly rather than by hand.
  • A GeoDataFrame in EPSG:4326 with the value column present and its missing values represented as nulls rather than as zeros or sentinel numbers.

Jump to heading Step-by-step solution

Jump to heading Step 1 — Choose the classification deliberately

The same data, four classification methodsOne layer of median household income across three hundred and twenty areas is classified four ways into five classes, and the number of areas falling in the darkest class is shown. Equal intervals splits the value range evenly and puts eleven areas in the top class, because income is right-skewed and the top of the range is sparsely populated — the map looks almost uniformly pale with a few outliers. Quantiles forces sixty-four areas into each class by construction, which produces an evenly coloured map that reads as more variation than exists. Natural breaks finds the gaps in the distribution and puts thirty-eight in the top class, which usually looks most like what a domain expert would draw by hand. A standard-deviation classification puts nineteen there and is only meaningful if the data is roughly normal, which income is not. None of the four is wrong; they answer different questions, and the legend is where the reader learns which was asked.MEDIAN INCOME, 320 AREAS, 5 CLASSES — AREAS IN THE DARKEST CLASSequal intervals11 areas — a pale map with a few outliersquantiles64 by construction —even colour, apparent variationnatural breaks38 — usually closest to a hand-drawn mapstandard deviation19 — only meaningful if the data is roughly normalFour defensible maps from one dataset. The choice is an editorial one, which is exactly why it belongs in thelegend title rather than in the code alone.
python
import mapclassify as mc

values = gdf["median_income"].dropna()
scheme = mc.NaturalBreaks(values, k=5)          # or Quantiles, EqualInterval
breaks = list(scheme.bins)                      # upper bound of each class
method_label = "natural breaks (Jenks), 5 classes"

Dropping nulls before classifying is deliberate: a null included as a zero drags the lowest break down and shifts every class, so the missing areas would distort the classes they are then excluded from.

Jump to heading Step 2 — Build one colormap and use it twice

Where each piece of the legend is builtThe colour scale, the class breaks and the rendered legend all have to agree, and the reliable way to guarantee that is to compute the breaks once in Python and derive both the feature styling and the legend markup from the same object. The breaks are computed from the data with an explicit method. The colormap is built from those breaks. Folium's style function reads the colormap per feature, and the same colormap object is added to the map as a caption-bearing legend. Building the legend separately — as a hand-written HTML block listing ranges somebody typed — is the arrangement that drifts, because a change to the class count updates the map and not the text beside it.ONE COLORMAP, TWO CONSUMERSbuildbreaks from the datacolormap from the breaksstyle_function + legendA hand-written legend block is the thing that drifts: change the class count and the map updates while the textbeside it quietly does not.
python
import branca.colormap as cm
import folium

colormap = cm.LinearColormap(
    colors=["#fdf3ee", "#f0c498", "#d99a5b", "#a3265b", "#2d1239"],
    vmin=float(values.min()), vmax=float(values.max()),
).to_step(index=[float(values.min())] + [float(b) for b in breaks])
colormap.caption = f"Median household income — {method_label}"

NO_DATA = {"fillColor": "#cccccc", "fillPattern": "hatch", "fillOpacity": 0.55}

def style_function(feature):
    value = feature["properties"].get("median_income")
    if value is None:
        return {"weight": 0.4, "color": "#888888", **NO_DATA}
    return {"fillColor": colormap(value), "fillOpacity": 0.8,
            "weight": 0.4, "color": "#ffffff"}

m = folium.Map(tiles="CartoDB positron")
folium.GeoJson(gdf.__geo_interface__, style_function=style_function,
               name="Median income").add_to(m)
colormap.add_to(m)                    # the legend, from the same object

to_step with an explicit index is what turns a continuous ramp into a stepped one matching the class breaks — so the legend shows five bands rather than a smooth gradient, which is what the data actually is. Showing a smooth colorbar over classified data tells the reader the encoding is continuous, and they will interpret intermediate shades that do not exist.

Jump to heading Step 3 — Give missing data a visible identity

The NO_DATA style above is the whole fix for the most common misreading. An area with no value styled as the palest colour in the ramp sits exactly where zero would be, and readers reliably interpret it as “very low” rather than “not measured”. A hatch reads as a different category at a glance, and it survives being printed in greyscale.

Add its legend entry explicitly, since a colormap only knows about the values it was built from:

python
legend_extra = """
<div style="position:fixed;bottom:96px;left:12px;z-index:9999;
            background:rgba(255,255,255,.92);padding:6px 10px;border-radius:6px;
            font:12px system-ui,sans-serif;">
  <span style="display:inline-block;width:12px;height:12px;background:#cccccc;
               border:1px solid #888;vertical-align:-1px;"></span>
  no data — not measured, not zero
</div>"""
m.get_root().html.add_child(folium.Element(legend_extra))

Jump to heading Step 4 — Caption the method, not just the variable

A legend headed “Median household income” tells the reader what is encoded. A legend headed “Median household income — natural breaks, 5 classes” tells them how to read it, and the difference matters: a quantile map is evenly coloured by construction, and a reader who assumes equal intervals will conclude the variable is far more evenly distributed than it is. One extra clause removes that misreading entirely.

Jump to heading Verification

python
# 1. Every non-null value maps to a colour inside the ramp.
assert all(colormap(v) is not None for v in values)

# 2. The class count in the legend matches the breaks used for styling.
assert len(colormap.index) - 1 == len(breaks), "legend and styling disagree"

# 3. Nulls take the no-data style rather than the lightest ramp colour.
sample = {"properties": {"median_income": None}}
assert style_function(sample)["fillColor"] == "#cccccc"

The second assertion is the one that catches drift. It fails the moment somebody changes k=5 to k=7 and updates only one of the two places the number appears — which is the failure the single-colormap arrangement is designed to make impossible, and this check confirms it stayed that way.

Jump to heading Edge cases and gotchas

  • Ties at a break. mapclassify puts a value equal to a break in the lower class. Where the breaks are round numbers this matters and readers will notice; nudge the breaks off round numbers or state the convention in the caption.
  • A single distinct value. Any classifier given a constant column produces degenerate breaks and a colormap that raises. Guard for it and render a single flat colour with an explanatory caption.
  • Very small areas. A choropleth encodes value by colour and area by geography, so a dense urban area with the highest value can be invisible. Consider a cartogram or an accompanying ranked list; no legend fixes an encoding the geometry defeats.
  • Printing and colour vision. A ramp that goes dark to light survives greyscale and most forms of colour-vision deficiency; one that goes red to green does not. Test by desaturating a screenshot.

Jump to heading FAQ

Should I use folium.Choropleth instead of a style function?

folium.Choropleth is quicker for a first draft and gives you much less control: it owns the classification, the colours and the legend, and getting a no-data treatment or a custom caption out of it means fighting it. A GeoJson layer with an explicit style function and a branca colormap is a few more lines and is the arrangement that survives requirements.

Can the legend be interactive?

Not through branca, which renders a static SVG into the map. If clicking a class to filter matters, render your own legend as page markup beside the map and drive the filter from it — the map then becomes a display of a filter the page owns, which is usually a better architecture anyway.

Why does my legend disappear on small screens?

The colormap is positioned absolutely inside the map container, and a short container clips it. Give the map a minimum height, or move the legend out of the map and into the page layout where the surrounding responsive rules apply to it.

Back to Folium & Leafmap Integration.