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. Two focused walkthroughs sit beneath it — Folium Choropleth from a GeoDataFrame and Clustering Map Markers with Folium — and both build on the object model described here.
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). Under the hood each of these is a branca.element node, and the map is a tree of them. When you call .save() or let a notebook render the object, Folium walks that tree and renders each node's Jinja2 template into HTML plus inline JavaScript that boots Leaflet in the browser. There is no server and no build step: the output is one static document that carries its own script.
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 geometries and the transforms in Coordinate Systems with PyProj. Every centroid or point you hand to Folium must be flipped. This axis inversion is the single most common source of "my markers are in the ocean" bug reports, and it recurs because the rest of the Python geospatial stack is x, y first.
Two container elements are worth knowing early. folium.FeatureGroup bundles related overlays so LayerControl can toggle them as a unit — one group per thematic layer. folium.map.Map.get_root() returns the top branca.element.Figure, which is where you reach in to inject custom CSS, a title <div>, or a legend when the high-level API does not expose a hook.
The Figure is worth understanding because it is the escape hatch for everything the typed API does not cover. It has three named children — header (where <link> and <style> go), html (the document body), and script (JavaScript that runs after the map is built) — and each accepts a raw folium.Element. That is how you add a title bar, a static legend, or a snippet of Leaflet code that the Python classes do not wrap:
import folium
flood_map = folium.Map(location=[45.07, 7.69], zoom_start=12, tiles="CartoDB positron")
root = flood_map.get_root()
root.header.add_child(folium.Element(
"<style>.map-title{font:600 16px system-ui;padding:8px 12px;color:#1d2d44}</style>"
))
root.html.add_child(folium.Element(
'<div class="map-title">Modelled 1-in-100-year flood extent</div>'
))
Note add_child versus add_to: they are the same edge in the tree read from opposite ends. parent.add_child(node) is what node.add_to(parent) calls internally, and add_to returns the child, which is why cluster = MarkerCluster().add_to(m) gives you the MarkerCluster rather than the map. Reaching for add_child is the habit to build when the parent is the Figure or a plugin container rather than the Map.
One more structural lever solves a problem people usually try to fix with layer ordering: panes. Leaflet draws overlays into stacking contexts with fixed z-indexes, so a polygon fill added after a tile layer still sits under labels from a labelled basemap. folium.map.CustomPane(name, z_index=...) creates a new context, and any layer constructed with pane="<name>" renders into it. That is the correct fix for "my choropleth hides the street names" — put the fill in a pane below the basemap's label pane rather than hunting for a different basemap.
Every element also carries an auto-generated _id used to name its JavaScript variables, which is why two maps rendered in the same notebook never collide, and why editing the saved HTML by hand is a dead end: the ids change on every render.
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 and default tile providers.
# 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 the GeoPandas .explore() convenience method (which renders through Folium) both use it for classification schemes such as quantiles and natural breaks. Without it, binned choropleths silently fall back to linear bins. For the analytical layer that produces these GeoDataFrames in the first place, see GeoPandas DataFrames Explained.
Three version boundaries change the answer to real questions, so check them before debugging anything else:
- Basemap shortcuts. The bundled Stamen aliases (
tiles="Stamen Terrain","Stamen Toner") were dropped in the 0.15/0.16 series after Stamen's tiles moved to Stadia Maps and began requiring an account. On current Folium those strings raise aValueErrortelling you to supply an explicit URL template andattr."OpenStreetMap","CartoDB positron"and"CartoDB dark_matter"remain built in. - GeoPandas 1.0.
GeoSeries.set_precision— the cheapest payload trim there is — only exists from 1.0; on 0.14 callshapely.set_precision(gdf.geometry.to_numpy(), 1e-6)instead. The same release deprecatedunary_unionin favour ofunion_all(), which is what every centroid-for-map-centre snippet on this site uses. mapclassifypresence. It is an optional dependency of both Folium and GeoPandas, and its absence changes behaviour rather than raising: binned classification quietly degrades to a linear ramp.
import folium
import geopandas as gpd
import shapely
print(folium.__version__, gpd.__version__, shapely.__version__) # 0.16.0 1.0.1 2.0.4
def to_web_precision(gdf: gpd.GeoDataFrame, grid: float = 1e-6) -> gpd.GeoDataFrame:
"""Trim coordinate precision on either GeoPandas generation."""
out = gdf.copy()
if hasattr(out.geometry, "set_precision"): # GeoPandas >= 1.0
out["geometry"] = out.geometry.set_precision(grid)
else: # GeoPandas 0.14 fallback
out["geometry"] = gpd.GeoSeries(
shapely.set_precision(out.geometry.to_numpy(), grid), crs=out.crs
)
return out
Rendering context matters as much as the packages. In classic Jupyter Notebook the map object renders inline automatically. In JupyterLab, VS Code notebooks, and Colab the same object usually works, but a large embedded GeoJSON can exceed the notebook's output cell limit and show a blank frame — save to HTML and open in a browser tab when that happens. Folium embeds every map inside a sandboxed <iframe>, so page-level CSS never leaks into the map, but it also means custom fonts or scripts must be attached to the map's own Figure root, not the surrounding page.
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, so there is no manual .to_json() step and no separate attribute table to key against.
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,
},
highlight_function=lambda feat: {"weight": 3, "fillOpacity": 0.6},
tooltip=folium.GeoJsonTooltip(fields=["district_name", "population"]),
popup=folium.GeoJsonPopup(fields=["district_name", "population"]),
).add_to(fmap)
folium.LayerControl().add_to(fmap)
fmap.save("districts.html")
The four hooks on folium.GeoJson do all the interactive work. style_function receives each GeoJSON feature and returns a style dict, so you can drive colour and stroke from any attribute. highlight_function restyles the feature under the cursor. GeoJsonTooltip shows fields on hover, and GeoJsonPopup shows them on click. Because these functions run once at render time in Python — not live in the browser — the styling is baked into the HTML; there is no reactive re-styling without a page reload, which is one of the boundaries that separates Folium from MapLibre GL Vector Web Maps.
For value-driven shading rather than a flat style, use a binned Choropleth — the full recipe is in Folium Choropleth from a GeoDataFrame. For dense point layers, wrap markers in a MarkerCluster rather than adding them one by one, as covered in Clustering Map Markers with Folium.
Two refinements turn that skeleton into something publishable. The first is framing: a hard-coded zoom_start is a guess that goes wrong the moment the data changes extent. fit_bounds computes the viewport from the layer itself, and it takes corners as [[south, west], [north, east]] — latitude first again, and in the opposite order to the (minx, miny, maxx, maxy) tuple GeoPandas hands you:
minx, miny, maxx, maxy = districts_wgs84.total_bounds # x/lon first, GeoPandas order
fmap.fit_bounds([[miny, minx], [maxy, maxx]], padding=(20, 20)) # y/lat first, Folium order
The second is the basemap. Folium's shortcut strings cover a handful of providers; anything else — a national mapping agency's tiles, an internal raster service, a hillshade — is a folium.TileLayer with an explicit URL template and an attr string. That attribution argument is not optional decoration: Folium raises a ValueError without it, deliberately, because almost every tile provider's licence requires visible credit. Passing overlay=False promotes a tile layer to a base layer, which LayerControl renders as mutually exclusive radio buttons rather than checkboxes:
folium.TileLayer(
tiles="https://tiles.example.org/hillshade/{z}/{x}/{y}.png",
attr="Terrain © National Mapping Agency",
name="Hillshade",
overlay=False, # radio button: one base map at a time
control=True,
max_zoom=15, # stop Leaflet requesting tiles the service does not have
).add_to(fmap)
Setting max_zoom to the deepest level the service actually publishes prevents a wall of 404s past that zoom; Leaflet will upscale the last available tile instead. For a first look at a layer with no styling decisions at all, GeoPandas' .explore() returns a Folium Map object built from exactly these pieces, so you can start with the one-liner and keep adding folium children to the object it hands back.
Geometry / Data Processing Details
Folium serializes whatever geometry you give it into inline GeoJSON, so the processing that keeps a map fast happens before binding, not inside Folium. The vectorized topological primitives here come from Shapely Geometry Operations via GeoPandas. 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, over-thinning near the poles and under-thinning near the equator. simplify() is a per-geometry Douglas-Peucker pass and can open gaps between shared borders; when adjacency must be preserved, simplify the shared topology instead, and validate the result. The topology rules behind safe simplification — and the fixes when a simplified polygon self-intersects — are covered in Topology Validation & Repair.
Precision trimming with set_precision is the cheapest win: raw shapefiles often carry 14 decimal places, which is nanometre precision no web map can use, and every extra digit is bytes in the HTML. Six decimals is roughly 11 cm at the equator — more than enough for a browser map.
Attributes need almost as much attention as geometry, because GeoJSON's type system is much narrower than pandas'. Three conversions bite in practice. Missing values become JSON null, which a tooltip renders as an empty cell rather than something readable — fill them with an explicit string before binding. datetime64 columns serialise to ISO strings, so a tooltip shows 2025-03-14T00:00:00 unless you format the column yourself. And categorical or extension dtypes are coerced on the way out, which occasionally produces nan as a literal string in the popup. Doing the formatting in pandas, where it is vectorized, is both faster and easier to test than doing it in a style_function:
import geopandas as gpd
permits = gpd.read_file("building_permits.gpkg").to_crs(epsg=4326)
permits["issued"] = permits["issued_at"].dt.strftime("%d %b %Y") # human-readable date
permits["valuation"] = permits["valuation_usd"].map(
lambda v: f"${v:,.0f}" if v == v else "not reported" # NaN-safe formatting
)
permits = permits[["permit_id", "issued", "valuation", "geometry"]]
Geometry type matters too. Folium serialises whatever GeoPandas gives it, so a layer holding both Polygon and MultiPolygon rows renders fine, but a GeometryCollection — the usual output of an intersection gone slightly wrong — is not something Leaflet draws reliably, and a null geometry raises during serialisation. Filter for the geometry types you intend to draw before binding, using the same discipline described in Handling Mixed Geometry Types in a GeoDataFrame.
One rendering-side knob is worth knowing: folium.GeoJson(..., smooth_factor=1.5) tells Leaflet how aggressively to drop vertices during drawing, in screen pixels, without altering the data. It is not a substitute for simplifying in Python — the full coordinate list is still in the file, so the payload is unchanged — but on a dense line layer it visibly reduces pan and zoom jank. Use it after you have trimmed the payload, never instead.
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 — just as important — you should never compute metric quantities after reprojecting to 4326, because degrees are not a length unit.
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, as the last step
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 and collapsed everything toward Null Island. Two subtler traps: a GeoDataFrame with crs=None will not be reprojected by to_crs() and Folium will place it wherever its raw numbers fall, so always set the CRS explicitly on load; and axis-order surprises from a stray PROJ string versus an EPSG code produce a map that is mirrored or rotated. The transformation mechanics and the 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. The whole dataset lives inline as GeoJSON, so the file is self-contained but grows linearly with the data. It is the wrong tool when:
- the GeoJSON embedded in the HTML exceeds a few megabytes (the browser stalls parsing it 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"))
To embed a saved map in an existing page, drop the HTML file into an <iframe src="sensors.html"> rather than pasting its markup — Folium already sandboxes its own content in an iframe, and nesting cleanly avoids CSS and id collisions with the host page. For a static PNG (a report figure, a thumbnail), render the HTML through a headless browser; Folium's built-in _to_png() needs Selenium and a driver, so a screenshot service is often simpler in CI.
There is a middle path between "everything inline" and "build a tile pipeline", and it is under-used: folium.GeoJson accepts embed=False when the data argument is a URL or a path, in which case Folium writes a reference and Leaflet fetches the file at view time. The HTML stays a few kilobytes, the data becomes a separately cacheable asset your CDN can gzip and version, and a repeat visitor re-downloads only what changed. The cost is that the map is no longer a single portable file, so it is the wrong choice for anything you intend to email.
import folium
fmap = folium.Map(location=[52.52, 13.40], zoom_start=10, tiles="CartoDB positron")
# The browser fetches this at view time; the HTML holds a URL, not 4 MB of coordinates.
folium.GeoJson(
"https://cdn.example.org/layers/sensors.v7.geojson",
embed=False,
name="Sensors",
tooltip=folium.GeoJsonTooltip(fields=["sensor_id", "pm25"]),
).add_to(fmap)
folium.LayerControl().add_to(fmap)
fmap.save("sensors.html")
Two rules make that pattern behave. Serve the GeoJSON from the same origin as the page or set permissive CORS headers on it, because Leaflet's fetch is subject to the same-origin policy and a missing Access-Control-Allow-Origin shows up as an empty map with a console error and nothing else. And put a version in the filename rather than relying on cache expiry, so you can serve the data with a far-future Cache-Control and still ship an update instantly.
The output shape is also worth controlling deliberately. folium.Map(width="100%", height=520) sizes the map div; wrapping the map in a folium.Figure(width=900, height=600) sizes the whole document, which is what you want when the file is destined for a fixed-width report frame. If you need the markup rather than a file — inserting into a templating system, returning from a web handler — fmap.get_root().render() gives you the complete HTML as a string, and fmap._repr_html_() gives the iframe-wrapped fragment a notebook would display.
When a map becomes an application rather than an artifact — filters, a date slider, a click that triggers a query — Folium stops being the whole answer and becomes the rendering half of one. The streamlit-folium bridge keeps this code intact and wraps it in a widget loop, covered in Geospatial Dashboards & App Deployment.
When the guardrail trips, the path forward is PMTiles: render the data server-side once, and the browser streams only the tiles in view. The pipeline is in Generating PMTiles from GeoParquet, which sits under Vector Tile Pipelines with PMTiles.
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.- Map renders blank in JupyterLab but fine when saved. The embedded payload exceeded the notebook output limit — open the saved HTML in a browser tab instead of the cell.
- 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 customfolium.TileLayer. 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. Folium writes UTF-8, but downstream tooling on Windows may default to cp1252 when it re-reads the file; open post-processing steps with
encoding="utf-8"explicitly. ValueError: Custom tiles must have an attribution. ATileLayerwith a URL template needsattr=. This is intentional — supply the provider's required credit string.tiles="Stamen Terrain"suddenly fails. The Stamen shortcuts were removed after those tiles moved to a commercial provider. Use a built-in alias or a full URL template withattr.- The layer control lists a layer that cannot be switched off. Base layers (
overlay=False) are radio buttons by design; only one can be active and none can be cleared. Add it as an overlay if you want a checkbox. - A second map in the same notebook shows the first map's data. You reused the
folium.Mapvariable after adding children. Elements attach to the object, not to the call — build a freshMapper figure. - The map renders behind other page content when embedded. Leaflet sets high z-indexes on its panes; scope your host page's CSS or place the map in an iframe rather than fighting the stacking order.
Frequently Asked Questions
When is Folium the right choice, and when have I outgrown it?
Folium is right whenever the deliverable is an artifact: a notebook figure, an attachment, a page in an internal report, a map someone opens once to answer a question. It is the fastest path from a GeoDataFrame to something a non-technical colleague can pan and click, and it needs no infrastructure at all. You have outgrown it when any of three things becomes true: the inline payload passes a few megabytes, styling needs to change in response to the user without a page reload, or the map is one control among several in an application. The first two point at MapLibre GL Vector Web Maps; the third points at a dashboard framework.
Why is my saved HTML so much larger than the source data file? Because GeoJSON is text and your source probably was not. A GeoPackage or a GeoParquet file stores coordinates as binary doubles; the same coordinates in GeoJSON become decimal strings with punctuation, which routinely triples the size before the surrounding JavaScript is counted. The three fixes compound and are all one line each: simplify in a metric CRS, trim precision to six decimals, and drop every column the map does not display. If that is not enough, the data does not belong inline.
Can I update a Folium map without regenerating the whole file?
Not through the Python API — every style decision is evaluated at save time and frozen into the document, so "update" means "re-render". Two workarounds cover most needs. Point the layer at an external URL with embed=False and republish only the data file, or inject the reactive part as a folium.Element of raw Leaflet JavaScript on the Figure root. Once you are writing more than a few lines of that JavaScript, a renderer designed for client-side styling is the cheaper answer.
Do I need a basemap at all?
No, and dropping it is sometimes the right call. folium.Map(tiles=None) gives an empty canvas, which suits a map of a self-contained study area — a site plan, a set of catchments — where a global street map adds noise and an attribution obligation. It also removes the network dependency, which is the difference between a report that renders on an air-gapped machine and one that shows a grey rectangle.
How do I stop the map from being blank when there is no internet?
Everything except the basemap is already local: geometry, styles and the Leaflet bundle are embedded in the file. Only tile requests reach the network. For offline use, either set tiles=None and rely on your own layers, or point a TileLayer at a local tile source served from disk. Test it by loading the saved file with the network disabled rather than by trusting the notebook preview, which may still be serving warm tiles from the browser cache.
Which Folium plugins are actually worth reaching for?
The ones that solve a real interaction problem rather than decorating the map. MarkerCluster and FastMarkerCluster for dense points, HeatMap for density where identity does not matter, Fullscreen and MousePosition for anything a colleague will explore themselves, Draw when you need geometry back out of the map, and TimestampedGeoJson for animating a time series — bearing in mind that last one wants ISO 8601 times in specific feature properties and gets slow well before the other layers do. Everything under folium.plugins is a wrapper around a Leaflet plugin, so when a plugin behaves oddly, the Leaflet plugin's own documentation is the authoritative source.