Adding a Legend and Colorbar to a Folium Choropleth
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.
Jump to heading Prerequisites
folium>=0.15andbranca>=0.7, which supplies the colormap objects Folium’s legends are built from.mapclassify>=2.6if you want natural breaks or quantiles computed properly rather than by hand.- A
GeoDataFramein 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
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
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:
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
# 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.
mapclassifyputs 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.