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.

Folium object model A Folium Map object holds a TileLayer base map plus stacked overlays — GeoJson, Choropleth, and MarkerCluster — all controlled by a LayerControl, then saved to a single HTML file. folium.Map location, zoom_start TileLayer (base) LayerControl .save("map.html") GeoJson overlay Choropleth overlay MarkerCluster overlay map.html self-contained, shareable
Every Folium map is a tree: a Map holds a base TileLayer and any number of overlays, exported as one HTML file.

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:

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