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

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