How to Make a Folium Choropleth With Tooltips in Python

On this page

Problem statement

Folium's Choropleth colours polygons by a value and draws a legend, but it does not show the value when you hover over a polygon, which is the first thing readers try. The usual answer is a second layer: a GeoJson layer with a GeoJsonTooltip that reads fields from each feature's properties. Getting the two to work together means putting the data into the GeoJSON itself, choosing class breaks that suit skewed data, and keeping the file small.

This page maps the 2023 population density of US counties with folium.Choropleth, adds hover tooltips with a GeoJsonTooltip, uses quantile class breaks instead of equal intervals, highlights the county under the cursor, and measures the HTML file.

Quick answer

Draw the colours with Choropleth, then add a transparent GeoJson layer whose tooltip reads the properties:

import folium
m = folium.Map(location=[38, -96], zoom_start=4)
folium.Choropleth(geo_data=GJ, data=G, columns=["GEOID", "density_km2"], key_on="feature.properties.GEOID",
                  bins=list(BINS), fill_color="YlGnBu", fill_opacity=0.8, line_opacity=0.1, legend_name="people per km² (quantiles)").add_to(m)
folium.GeoJson(GJ, style_function=lambda f: {"fillOpacity": 0, "weight": 0},
               tooltip=folium.GeoJsonTooltip(fields=["NAME", "density_txt", "pop_txt"], aliases=["County", "People per km²", "Population"])).add_to(m)
m.save("data/county_choropleth.html")
print(f"counties: {len(G):,}; bins: {[round(float(b), 1) for b in BINS]}; HTML {Path('data/county_choropleth.html').stat().st_size / 1e6:.2f} MB")
counties: 3,109; bins: [0.0, 4.7, 11.9, 23.9, 62.8, 363.9, 17226.7]; HTML 3.54 MB

The quantile breaks are 4.7, 11.9, 23.9, 62.8 and 363.9 people per km², ending at the maximum, 17,226.7. With the full geometry stored twice, once for the colours and once for the tooltips, the page is 3.54 MB.

Screenshot of the Folium county density choropleth zoomed to the north-east US, with a tooltip over Howard County, Maryland reading 492.6 people per km² and population 336,001. Screenshot of the Folium county density choropleth zoomed to the north-east US, with a tooltip over Howard County, Maryland reading 492.6 people per km² and population 336,001.
Hovering a county shows its tooltip.

Data in the GeoJSON, breaks for skewed data, highlights

1. Put the tooltip data in the GeoJSON

GeoJsonTooltip can only show properties of the features. Merge the values into the GeoDataFrame before converting, as formatted strings so the tooltip reads well:

props = GJ["features"][0]["properties"]
print(f"properties of the first feature: {props}")
print(f"features: {len(GJ['features']):,}; all have the tooltip fields: {all({'NAME', 'density_txt', 'pop_txt'} <= f['properties'].keys() for f in GJ['features'])}")
properties of the first feature: {'GEOID': '13027', 'NAME': 'Brooks County, Georgia', 'density_km2': 12.823392234258126, 'density_txt': '12.8', 'pop_txt': '16,245'}
features: 3,109; all have the tooltip fields: True

Every feature carries the tooltip fields. The first is Brooks County, Georgia, with 16,245 people at 12.8 per km². The number is stored both raw, for the colour scale, and as a formatted string, for the tooltip.

2. Class breaks for skewed data

Choropleth uses six equal-width classes by default. On densities from almost zero to over 17,000 people per km², almost every county lands in the first class. Compare with quantile breaks:

eq = np.linspace(G.density_km2.min(), G.density_km2.max(), 7)
print("equal-interval classes:", pd.cut(G.density_km2, eq, include_lowest=True).value_counts(sort=False).tolist())
print("quantile classes:      ", pd.cut(G.density_km2, BINS, include_lowest=True).value_counts(sort=False).tolist())
equal-interval classes: [3097, 7, 2, 2, 0, 1]
quantile classes:       [622, 622, 621, 622, 466, 156]

With equal intervals, 3,097 of the 3,109 counties fall into the lowest class, below about 2,870 people per km², and the map is a single colour. The quantile classes hold 622, 622, 621 and 622 counties, then 466 and 156: the last two classes split the top 20% at the 95th percentile, so the densest 5% get their own colour.

Two maps of US county density in the same six colours: with equal intervals almost the whole country is the palest colour; with quantile breaks the cities, the eastern states and the sparse West are distinct. Two maps of US county density in the same six colours: with equal intervals almost the whole country is the palest colour; with quantile breaks the cities, the eastern states and the sparse West are distinct.
Equal intervals against quantiles.

3. Highlight on hover

A highlight_function restyles the feature under the cursor, which makes it clear which polygon the tooltip describes:

m2 = folium.Map(location=[38, -96], zoom_start=4)
folium.GeoJson(GJ, style_function=lambda f: {"fillColor": colour(f["properties"]["density_km2"]), "color": "white", "weight": 0.3, "fillOpacity": 0.8},
               highlight_function=lambda f: {"weight": 2, "color": "#1e293b"},
               tooltip=folium.GeoJsonTooltip(fields=["NAME", "density_txt"], aliases=["County", "People per km²"])).add_to(m2)
m2.save("data/county_highlight.html")
print(f"single-layer version, styled per feature: HTML {Path('data/county_highlight.html').stat().st_size / 1e6:.2f} MB")
single-layer version, styled per feature: HTML 1.78 MB

Drawing the colours and the tooltip from one GeoJson layer halves the file to 1.78 MB, because the geometry is stored only once.

What the tooltip shows for a few counties

The data used on this page

The US Census Bureau's 2023 county cartographic boundaries (1:20 million) and Vintage 2023 county population estimates, with areas measured in the US Albers equal-area projection (EPSG:5070). Geometry is simplified to 0.01° to keep the page small. CARTO's raster basemaps, which many Folium examples load with tiles="CartoDB positron", now return a grey tile stamped "API KEY REQUIRED" (checked on 2 October 2026), so this page uses Folium's default OpenStreetMap tiles, within the OpenStreetMap tile usage policy.

import json
from pathlib import Path

import branca.colormap as cm
import geopandas as gpd
import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
def cached(path, url):
    p = Path(path)
    if not p.exists():
        r = requests.get(url, timeout=600)
        r.raise_for_status()
        p.write_bytes(r.content)
    return p
C = gpd.read_file(cached("data/cb_2023_us_county_20m.zip", "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_20m.zip")).to_crs(4326)
est = pd.read_csv(cached("data/co-est2023-alldata.csv", "https://www2.census.gov/programs-surveys/popest/datasets/2020-2023/counties/totals/co-est2023-alldata.csv"),
                  encoding="latin-1", dtype={"STATE": str, "COUNTY": str})
est = est[est.SUMLEV == 50].assign(GEOID=lambda d: d.STATE + d.COUNTY)[["GEOID", "STNAME", "CTYNAME", "POPESTIMATE2023"]]
G = C[~C.STATEFP.isin(["02", "15", "72"])].merge(est, on="GEOID")
G["density_km2"] = G.POPESTIMATE2023 / (G.to_crs(5070).area / 1e6)
G["NAME"] = G.CTYNAME + ", " + G.STNAME
G["density_txt"] = G.density_km2.map("{:,.1f}".format)
G["pop_txt"] = G.POPESTIMATE2023.map("{:,}".format)
G["geometry"] = G.geometry.simplify(0.01, preserve_topology=True)
GJ = json.loads(G[["GEOID", "NAME", "density_km2", "density_txt", "pop_txt", "geometry"]].to_json())
BINS = G.density_km2.quantile([0, 0.2, 0.4, 0.6, 0.8, 0.95, 1]).values
colour = cm.StepColormap(["#ffffcc", "#c7e9b4", "#7fcdbb", "#41b6c4", "#2c7fb8", "#253494"], index=list(BINS), vmin=BINS[0], vmax=BINS[-1])
print(f"{len(G):,} counties in the conterminous US; median density {G.density_km2.median():.1f} per km²")
3,109 counties in the conterminous US; median density 17.0 per km²

The data covers the 3,109 counties and county equivalents of the conterminous US. The median density is 17.0 people per km².

Example 1: what the tooltip shows for a few counties

Check the strings that will appear on hover for the densest and the sparsest counties:

for _, r in pd.concat([G.nlargest(2, "density_km2"), G.nsmallest(2, "density_km2")]).iterrows():
    print(f"{r.NAME:>32}: {r.density_txt:>9} people per km², population {r.pop_txt}")
       New York County, New York:  17,226.7 people per km², population 1,597,451
          Kings County, New York:   9,667.2 people per km², population 2,561,225
            Loving County, Texas:       0.0 people per km², population 43
            Kenedy County, Texas:       0.1 people per km², population 343

Manhattan (New York County) is the densest county, at 17,226.7 people per km², followed by Brooklyn (Kings County). Loving County, Texas, with 43 people, is shown as 0.0 to one decimal place, because its true density is below 0.05.

Tooltip strings for the densest and sparsest US counties. what the tooltip showscountypeople per km²populationNew York County, NY17,226.71,597,451Kings County, NY9,667.22,561,225Loving County, TX0.043Kenedy County, TX0.1343strings formatted in pandas before to_json, so the tooltip needs no formattingCensus Vintage 2023 estimates, areas in EPSG:5070 spatialworkflow.io
The tooltip strings, formatted in pandas.

What folium.Choropleth does and does not attach

The two layers of a Folium choropleth with tooltips, and the single-layer alternative. what the page is made ofGeoJson + GeoJsonTooltipinvisible, reads propertiesgeometry againfolium.Choroplethcolours + legend, no values3,109 countiestilesOpenStreetMaploaded at view timeTwo layers 3.54 MB; one GeoJson layer styled per feature 1.78 MB spatialworkflow.io
What the page is made of.

folium.Choropleth is a convenience: it joins a DataFrame to GeoJSON features through key_on, classifies the values into bins, colours each feature and adds a legend. It does not attach the values to the features, so a tooltip has nothing to show; hence the common pattern of a second, invisible GeoJson layer carrying the tooltip, or a single GeoJson layer styled per feature with a colour function, which gives full control and one copy of the geometry.

Class breaks shape the reading. Equal-width bins on skewed data put almost everything in one class; quantiles put the same number of areas in each class, so the map shows rank rather than magnitude, and the legend must say so. The geometry is the bulk of the file: two layers of the same counties double it, and simplifying boundaries for a national map makes no visible difference while keeping the page responsive.

Key paths, timestamps and missing values

Folium choropleth problems and fixes. choropleth trapsSymptomFixnothing coloured, no errorkey_on="feature.properties.GEOID"no values on hoverGeoJsonTooltip on the propertiesone colour everywherequantile bins: 3,097 in one classTimestamp not serializableconvert dates to stringsUS county population density, 2023 spatialworkflow.io
Choropleth traps.
  • key_on path: it must start with feature., for example feature.properties.GEOID; a wrong path colours nothing and raises no error.
  • Timestamps in properties: Timestamp values cannot be serialised to JSON; convert them to strings first, see the serialisation fix.
  • Missing values: counties without data get nan_fill_color; set it so gaps are visible.
  • Bins and the legend: with quantile bins, say so in legend_name.

FAQ

How do I add tooltips to a Folium choropleth?

Add a folium.GeoJson layer with tooltip=folium.GeoJsonTooltip(fields=[...]), with the values stored in each feature's properties.

Why does my Folium choropleth not show values on hover?

Choropleth does not add tooltips; the values must be in the GeoJSON properties and shown with a GeoJsonTooltip.

How do I change the class breaks in Folium?

Pass a list of break values as bins, for example quantiles of the data.

Why is my Folium map file so large?

Each GeoJSON layer embeds the full geometry; simplify it and avoid duplicating layers.