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.

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). 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:

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.

How the three trimming steps shrink the inline GeoJSON payload Four horizontal bars show the size of the GeoJSON that Folium embeds in the HTML file for the same boundary layer. The raw layer serialises to 18.4 megabytes. Simplifying with a 50 metre tolerance in a projected CRS cuts it to 6.2 megabytes. Trimming coordinate precision to six decimals brings it to 3.4 megabytes. Keeping only the two attribute columns the tooltip needs leaves 2.1 megabytes. A dashed vertical line marks the rough five megabyte ceiling above which the browser stalls parsing the file; the first two bars cross it and the last two do not. Three trimming steps decide whether the map opens ≈5 MB browser ceiling above this line, move to vector tiles raw layer, serialised 18.4 MB + simplify, 50 m in UTM 6.2 MB + 6-decimal precision 3.4 MB + two columns kept 2.1 MB Each step is one line of GeoPandas, and together they take the same layer from unopenable to instant.
Simplifying in metres, trimming precision and dropping unused columns compound: the same boundary layer falls from 18.4 MB of inline GeoJSON to 2.1 MB.

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"
Project-first, reproject-last CRS pipeline for Folium All metric analysis — area, distance, buffer — runs in the projected CRS EPSG:25832. A single reproject-to-4326 step, marked "last step only", hands the data to folium.GeoJson and then the browser. A separate branch, measuring area after reprojecting to 4326, is marked prohibited because degrees are not metres. Metric analysis zone EPSG:25832 projected CRS · metres .area → m² .distance → m .buffer → m reproject → 4326 (last step only) folium.GeoJson EPSG:4326 browser map .area after to_crs(4326) degrees are not metres — wrong result
Do every metric calculation in the projected CRS; reproject to EPSG:4326 only as the final hand-off to Folium. Measuring area after that step returns nonsense — degrees are not a length unit.

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:

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

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.