Interactive Maps with Folium
Folium is the fastest route from a GeoDataFrame to a shareable, interactive map. It wraps the Leaflet.js library and renders to a self-contained HTML file, which makes it the default visualization tool inside notebooks and internal dashboards across Web Mapping & Interactive Visualization. This guide covers the object model, the data-binding workflow, and the production limits that push you toward MapLibre GL Vector Web Maps or Vector Tile Pipelines with PMTiles once datasets grow.
Architecture & Data Structures
Folium mirrors Leaflet's object model in Python. A folium.Map is the root container; everything else — tile layers, GeoJSON overlays, markers, choropleths — is a child element added with .add_to(map). When you call .save() or let a notebook render the object, Folium serializes the tree to HTML plus inline JavaScript that boots Leaflet in the browser. There is no server: the output is static.
import folium
# The Map is the root of the element tree
city_map = folium.Map(
location=[45.07, 7.69], # [lat, lon] — note the axis order
zoom_start=12,
tiles="CartoDB positron",
control_scale=True,
)
# Children attach to the root
folium.Marker([45.07, 7.69], tooltip="City center").add_to(city_map)
folium.LayerControl().add_to(city_map)
city_map.save("city.html")
The critical structural fact: Folium takes coordinates as [latitude, longitude], the reverse of the (x, y) = (lon, lat) convention used by Shapely and PyProj. Every centroid or point you hand to Folium must be flipped.
Environment Configuration & Dependency Resolution
Folium is pure Python and installs cleanly from either conda-forge or PyPI. Pair it with GeoPandas so you can feed geometries directly. Pin versions — Folium's bundled Leaflet release changes between minor versions and can alter default styling.
# conda-forge is the most reliable for the GeoPandas binary stack
conda install -c conda-forge "folium=0.16.*" "geopandas=0.14.*" "mapclassify=2.6.*"
mapclassify is an easy dependency to forget: Folium's Choropleth and GeoPandas .explore() both use it for classification schemes (quantiles, natural breaks). Without it, binned choropleths silently fall back to linear bins. For the analytical layer that produces these GeoDataFrames, see GeoPandas DataFrames Explained.
Vectorized Operations & Core Workflow
The core workflow is: analyse in a projected CRS, reproject to EPSG:4326, then bind the GeoDataFrame to a Folium layer. folium.GeoJson accepts a GeoDataFrame directly and handles serialization.
import folium
import geopandas as gpd
# districts were dissolved and measured in EPSG:32632 (UTM 32N) upstream
districts = gpd.read_file("districts_utm.gpkg")
districts_wgs84 = districts.to_crs(epsg=4326)
center = districts_wgs84.geometry.union_all().centroid
fmap = folium.Map(location=[center.y, center.x], zoom_start=11, tiles="CartoDB positron")
folium.GeoJson(
districts_wgs84,
name="Districts",
style_function=lambda feat: {
"fillColor": "#3e5c76",
"color": "#1d2d44",
"weight": 1,
"fillOpacity": 0.4,
},
tooltip=folium.GeoJsonTooltip(fields=["district_name", "population"]),
).add_to(fmap)
folium.LayerControl().add_to(fmap)
fmap.save("districts.html")
For value-driven shading rather than a flat style, use a binned Choropleth — the full recipe is in Folium Choropleth from a GeoDataFrame.
Geometry / Data Processing Details
Folium serializes whatever geometry you give it, so the processing happens before binding. Three steps keep payloads small and rendering smooth:
import geopandas as gpd
boundaries = gpd.read_file("admin_boundaries.gpkg").to_crs(epsg=4326)
# 1. Simplify in a projected CRS so the tolerance is in metres, then go back
boundaries_m = boundaries.to_crs(boundaries.estimate_utm_crs())
boundaries_m["geometry"] = boundaries_m.geometry.simplify(50) # 50 m tolerance
boundaries = boundaries_m.to_crs(epsg=4326)
# 2. Trim coordinate precision (6 decimals ≈ 0.11 m)
boundaries["geometry"] = boundaries.geometry.set_precision(1e-6)
# 3. Keep only attributes the tooltip needs
boundaries = boundaries[["admin_name", "population", "geometry"]]
Simplify before reprojecting back to 4326 so the tolerance has metric meaning — simplifying in degrees applies an inconsistent tolerance across latitudes. The topology rules behind safe simplification are covered in Topology Validation & Repair.
CRS Alignment & Projection Pipeline
Folium assumes EPSG:4326 input and projects to Web Mercator (EPSG:3857) internally for tile placement. You should never hand Folium projected coordinates, and you should never compute metric quantities after reprojecting to 4326.
import geopandas as gpd
parcels = gpd.read_file("parcels.gpkg") # EPSG:25832
# Metric work happens here, in the projected CRS
parcels["area_ha"] = parcels.geometry.area / 1e4
# Convert to WGS84 strictly for Folium
parcels_web = parcels.to_crs(epsg=4326)
assert parcels_web.crs.to_epsg() == 4326, "Folium needs EPSG:4326"
If features land off the coast of West Africa (near 0°, 0°), you reprojected too late or not at all — the renderer interpreted UTM metres as degrees. The transformation mechanics and always_xy axis pitfalls are detailed in Coordinate Systems with PyProj.
Production Export & Integration
Folium's output is a single HTML file, which is its strength and its ceiling. It is excellent for emailing a result, embedding in an internal report, or rendering in a notebook. It is the wrong tool when:
- the GeoJSON embedded in the HTML exceeds a few megabytes (the browser stalls on load);
- you need data-driven styling that updates without a reload;
- thousands of point markers must render — use clustering, covered in Clustering Map Markers with Folium, or move to vector tiles.
import os
import geopandas as gpd
import folium
sensors = gpd.read_file("sensors.gpkg").to_crs(epsg=4326)
# Guardrail: warn before embedding a heavy payload
geojson_bytes = len(sensors.to_json().encode("utf-8"))
if geojson_bytes > 5_000_000:
raise RuntimeError(
f"Payload {geojson_bytes/1e6:.1f} MB exceeds 5 MB — generate vector tiles instead."
)
fmap = folium.Map(location=[52.52, 13.40], zoom_start=10)
folium.GeoJson(sensors, name="Sensors").add_to(fmap)
fmap.save(os.path.join("public", "sensors.html"))
When the guardrail trips, the path forward is PMTiles — see Generating PMTiles from GeoParquet.
Windows / Platform Edge Cases & Debugging
- Blank map in a notebook. Usually a missing internet connection (tile requests fail) or a CRS issue placing data off-screen. Add
folium.LatLngPopup()and click to confirm the viewport is where you expect. map.htmlis enormous. The whole GeoJSON is embedded inline. Simplify, trim precision, and drop columns; past ~5 MB switch to tiles.- Tiles don't load offline. Folium fetches tiles from a remote provider at view time. For air-gapped Windows machines, host a local tile source or use
tiles=Nonewith a customTileLayer. mapclassifynot found. Binned choropleths need it;conda install -c conda-forge mapclassify.- Markers in the wrong place. Coordinates passed as
[lon, lat]. Folium wants[lat, lon]— flip them. - Encoding errors on Windows when saving. Pass UTF-8 explicitly if you post-process the HTML; Folium writes UTF-8 but downstream tooling on Windows may default to cp1252.