Folium Choropleth from a GeoDataFrame
This guide builds a binned, value-shaded choropleth directly from a GeoDataFrame, the way an analyst actually has their data — not from the separate GeoJSON-plus-CSV inputs Folium's older API assumed. It is written for anyone who has a polygon layer with a numeric column and wants a clean interactive map. It sits under Interactive Maps with Folium in the Web Mapping & Interactive Visualization domain.
Why This Approach / What Goes Wrong
Folium ships two ways to make a choropleth. The legacy folium.Choropleth class wants a GeoJSON file and a separate DataFrame joined on a key_on string — a fragile setup where a single key mismatch produces a map of uniformly grey polygons with no error. The modern approach binds a single GeoDataFrame through folium.GeoJson with a classified style_function, so the geometry and the value travel together and there is no join to break.
The other common failure is classification. A linear color ramp on skewed data (population, income, emissions) paints almost everything the same shade because a few outliers stretch the scale. Quantile or natural-breaks classification via mapclassify fixes this by binning on the data distribution instead of its raw range.
Prerequisites
geopandas>=0.14folium>=0.16mapclassify>=2.6(required for binning schemes; Folium uses it silently)branca>=0.7(color maps and the legend; installed with Folium)
conda install -c conda-forge "geopandas=0.14.*" "folium=0.16.*" "mapclassify=2.6.*"
Step-by-Step Implementation
1. Load the layer and reproject for the web. Keep any metric computation in a projected CRS first.
import geopandas as gpd
# census_tracts carries a "median_income" column, analysed in EPSG:32610 upstream
census_tracts = gpd.read_file("census_tracts.gpkg")
census_tracts = census_tracts.to_crs(epsg=4326) # Folium needs WGS84
2. Classify the values with mapclassify. Quantiles give visually balanced bins on skewed data.
import mapclassify
classifier = mapclassify.Quantiles(census_tracts["median_income"], k=5)
census_tracts["bin"] = classifier.yb # integer bin index 0..4
3. Map bins to a color ramp. Use a sequential branca colormap stepped into the same number of classes.
import branca.colormap as cm
palette = ["#f0ebd8", "#9bb3c9", "#748cab", "#3e5c76", "#1d2d44"]
colormap = cm.StepColormap(
palette,
vmin=census_tracts["median_income"].min(),
vmax=census_tracts["median_income"].max(),
index=[classifier.bins[i] for i in range(-1, len(classifier.bins))][1:],
caption="Median income (USD)",
)
4. Build the map with a classified style function.
import folium
center = census_tracts.geometry.union_all().centroid
income_map = folium.Map(location=[center.y, center.x], zoom_start=11, tiles="CartoDB positron")
folium.GeoJson(
census_tracts,
name="Median income",
style_function=lambda feat: {
"fillColor": palette[feat["properties"]["bin"]],
"color": "#1d2d44",
"weight": 0.6,
"fillOpacity": 0.78,
},
tooltip=folium.GeoJsonTooltip(
fields=["tract_id", "median_income"],
aliases=["Tract", "Median income"],
),
).add_to(income_map)
colormap.add_to(income_map) # legend
folium.LayerControl().add_to(income_map)
income_map.save("income_choropleth.html")
Verification
Confirm the bins are populated and the output is reasonable before sharing it.
# Each quantile bin should hold a similar count of tracts
print(census_tracts["bin"].value_counts().sort_index())
# 0 104
# 1 103
# 2 104
# 3 103
# 4 104
# The saved file should be well under the 5 MB inline-payload ceiling
import os
size_mb = os.path.getsize("income_choropleth.html") / 1e6
print(f"Output: {size_mb:.2f} MB") # Output: 1.84 MB
assert size_mb < 5, "Too large for inline GeoJSON — switch to vector tiles"
Roughly equal bin counts confirm the quantile classifier worked; a flat-grey map means the bin lookup failed.
Edge Cases & Debugging
- All polygons one color. The
style_functionis reading the wrong property name, or you used the legacyChoroplethwith a brokenkey_on. Printcensus_tracts.columnsand confirmbinexists. NaNvalues break classification.mapclassifycannot bin nulls. Drop or impute first:census_tracts = census_tracts.dropna(subset=["median_income"]).- Legend missing. You forgot
colormap.add_to(income_map), orbrancais outdated. - Skewed, unreadable ramp. Switch
Quantilestomapclassify.NaturalBreaksfor clustered data, ormapclassify.UserDefinedfor domain thresholds. - File too big to share. Simplify geometry (in a projected CRS) and trim precision; past 5 MB, move to Generating PMTiles from GeoParquet.