Folium and Leafmap solve two different halves of the same problem. Folium gives you a battle-tested Python wrapper around Leaflet.js with a deep plugin ecosystem; Leafmap gives you a terse, Pythonic API for tile providers, raster/vector loading, and ready-made UI controls. Wiring them together — and then making that combined object render reliably inside a reactive framework like Streamlit or Panel — is where most spatial dashboard projects stall. This page treats that integration as a single workflow you can master end to end: initialize with Leafmap, drop down to a standard folium.Map for plugin and data binding, then hand a clean object to the framework bridge without triggering full-page reloads.

This work sits inside the broader Spatial Component Integration & Interactive Maps topic area. If your dashboard already renders point markers but you need GPU rendering for hundreds of thousands of features, Deck.gl Advanced Layers is the WebGL escape hatch; if you need map selections to drive downstream charts, Dynamic Spatial Filtering covers the reactive query side, and Tooltip & Click Event Handling covers reading interactions back out of the map.

Jump to heading The problem this workflow solves

The friction is rarely either library on its own — it is the seam between them and the framework. Leafmap returns a leafmap.foliumap.Map subclass, but streamlit-folium and Panel’s HTML panes expect a plain folium.Map. Pass the wrong object and you get either a silently blank canvas or a stale render that never updates on rerun. Layer on top of that the usual spatial hazards — a GeoDataFrame in a projected CRS that lands features in the wrong hemisphere, a plugin whose JavaScript is blocked by a corporate firewall, a 60 MB GeoJSON that freezes the browser tab — and “just show a map” becomes a multi-day debugging session.

The workflow below makes each of those failure modes explicit and orders the steps so that conversion, plugin injection, and data binding happen in the only sequence that survives a framework rerun. Everything is grounded in a standard folium.Map handed to the bridge as the last step, so the reactive layer always receives an object it understands.

Folium and Leafmap integration data flowA horizontal pipeline split into a Python tier and a browser tier. In Python, a leafmap.Map is converted by to_folium() into a plain folium.Map. Plugins (MarkerCluster, MiniMap) and a CRS-normalized GeoJSON layer reprojected to EPSG:4326 are bound to that folium.Map. The fully assembled map is passed to a framework bridge — st_folium or a Panel HTML pane — which crosses into the browser tier where Leaflet renders the tiles and layers. On each user click or draw, the bridge emits returned_objects (last_object_clicked, all_drawings) back across the boundary into Python, where they drive the next rerun and feed the pipeline again.PYTHON (assembly)leafmap.Maptiles + controlsfolium.Mapplain instanceFramework bridgest_folium / HTML paneto_folium()Bind layersplugins + EPSG:4326BROWSER (Leaflet render)Leaflet canvastiles + featuresreturned_objects → rerun

Jump to heading Prerequisites

This workflow assumes Python 3.9+ and familiarity with coordinate reference systems and the reactive execution model that reruns your whole script on every interaction. If the rerun model is new to you, read how it interacts with widget identity in Widget Lifecycle Management before wiring callbacks, and how to persist selections across reruns in Session State Patterns.

Pin versions explicitly — both libraries move fast, and a mismatched Leaflet bundle is the single most common cause of silent render failures.

ComponentRecommended versionPurpose
Python>=3.9Base runtime
folium>=0.15.0Leaflet.js wrapper and plugin ecosystem
leafmap>=0.30.0High-level geospatial API and UI generators
streamlit-folium>=0.17.0Streamlit rendering bridge
panel>=1.3.0Alternative dashboard framework
geopandas>=0.14.0Vector data and CRS handling

Install into an isolated environment:

bash
python -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate
pip install "folium>=0.15.0" "leafmap>=0.30.0" \
            "streamlit-folium>=0.17.0" "geopandas>=0.14.0" "panel>=1.3.0"

Jump to heading Core implementation workflow

The sequence is fixed: initialize → convert → inject plugins → bind data → render. Each step produces an object the next step expects, and reordering them is the source of most integration bugs.

Jump to heading 1. Initialize the base map with Leafmap

Start from Leafmap’s foliumap backend so you keep its tile providers and built-in controls while staying compatible with the Folium render bridge. New York City coordinates are used here as a concrete example.

python
import leafmap.foliumap as leafmap

m = leafmap.Map(
    center=[40.7128, -74.0060],  # NYC, WGS84 (lat, lon)
    zoom=10,
    draw_control=True,
    measure_control=True,
)

Jump to heading 2. Convert to a standard folium.Map

to_folium() strips the Leafmap subclass wrapper while preserving the layer stack and control state, returning the plain folium.Map instance the framework bridges require. Do this conversion before you touch plugins or data — anything you add to the Leafmap object after conversion will not appear in the converted copy.

python
folium_map = m.to_folium()
assert folium_map.__class__.__name__ == "Map"  # plain folium.Map, not a subclass

Jump to heading 3. Inject Folium plugins and custom layers

Add plugins to the converted object. Clustering keeps dense point layers interactive, and a minimap gives spatial context on a zoomed-in view.

python
import folium.plugins as plugins

marker_cluster = plugins.MarkerCluster(name="Incidents").add_to(folium_map)
plugins.MiniMap(toggle_display=True).add_to(folium_map)

Some plugins pull JavaScript from an external CDN. Behind a strict network policy, serve those assets from an internal static host instead — see the offline pattern below.

Jump to heading 4. Bind and CRS-normalize geospatial data

Leaflet, and therefore Folium, expects geographic coordinates in EPSG:4326. The framework bridges do not reproject for you, so a GeoDataFrame still in a projected CRS such as EPSG:3857 (Web Mercator metres) or a national grid will place features thousands of kilometres off. Normalize before binding. This CRS normalization step is the same discipline applied across every map component on this site.

python
import geopandas as gpd
import folium

gdf = gpd.read_file("data/parcels.geojson")

# Reproject to WGS84 only if needed
if gdf.crs is not None and gdf.crs.to_epsg() != 4326:
    gdf = gdf.to_crs("EPSG:4326")

folium.GeoJson(gdf, name="Parcels").add_to(folium_map)

For payloads beyond roughly 10–20 MB, bind a simplified or tiled version rather than the raw file — the deep dive on thresholds and progressive loading lives in Handling large GeoJSON files in Leafmap without browser lag.

Jump to heading 5. Render through the framework bridge

This is the last step, and it must receive the fully assembled folium_map. In Streamlit, pass an explicit key and a narrow returned_objects list so the component only re-emits the events you actually consume.

python
import streamlit as st
from streamlit_folium import st_folium

returned = st_folium(
    folium_map,
    key="parcels_map",                       # stable identity across reruns
    width=1200,
    height=600,
    returned_objects=["last_object_clicked", "all_drawings"],
)

if returned and returned.get("last_object_clicked"):
    st.write(f"Selected coordinates: {returned['last_object_clicked']}")

In Panel, render the map’s HTML into an HTML pane:

python
import panel as pn

map_pane = pn.pane.HTML(
    folium_map._repr_html_(),
    height=600,
    sizing_mode="stretch_width",
)
pn.Column("## Spatial Dashboard", map_pane).servable()

Jump to heading Advanced patterns

Jump to heading Map-driven cross-component filtering

A map is most useful when a selection on it filters the rest of the dashboard. Capture last_object_clicked or all_drawings from the bridge, store only the lightweight geometry or feature ID in session — never the whole map object — and use it to filter a pandas or polars frame downstream. The reactive query pipeline behind this, including how to keep responses fast on user-drawn bounding boxes, is the subject of Dynamic Spatial Filtering.

python
import json
from shapely.geometry import shape

drawings = (returned or {}).get("all_drawings") or []
if drawings:
    aoi = shape(drawings[-1]["geometry"])  # last polygon the user drew
    visible = gdf[gdf.intersects(aoi)]
    st.session_state["selected_ids"] = visible["parcel_id"].tolist()

Jump to heading Lazy, cached data loading

Reading and reprojecting a GeoDataFrame on every rerun is wasteful. Wrap the load in a cache so the parsed, WGS84-normalized frame is computed once per input. Combine this with the broader strategies in Caching Strategies & Async Performance Tuning, and lift tile fetches off the UI thread with the techniques in Async Data Loading Patterns.

python
@st.cache_data(show_spinner="Loading parcels…")
def load_parcels(path: str) -> gpd.GeoDataFrame:
    gdf = gpd.read_file(path)
    if gdf.crs is not None and gdf.crs.to_epsg() != 4326:
        gdf = gdf.to_crs("EPSG:4326")
    return gdf

Jump to heading WebGL fallback for heavy renders

Leaflet’s DOM-based rendering degrades once point counts pass roughly 10,000, or when 3D extrusion and animated flows are required. Keep the Folium control surface for light layers and offload the heavy layer to a GPU pipeline. Export the GeoDataFrame to GeoJSON or Parquet and feed it into the pydeck bridge described in Deck.gl Advanced Layers, so both engines read one shared data source.

Jump to heading Offline and air-gapped deployments

Internal tooling teams often deploy where external CDNs are unreachable. Point Folium’s tiles parameter at an internal tile server and host the Leaflet and plugin assets locally.

python
import folium

folium_map = folium.Map(
    location=[40.7128, -74.0060],
    zoom_start=10,
    tiles="http://internal-tiles.corp/{z}/{x}/{y}.png",
    attr="Internal basemap",
)

Jump to heading Verification & testing

Confirm the contract at each seam rather than eyeballing the rendered map. First, assert the conversion produced a plain folium.Map and that your data actually landed in WGS84:

python
assert isinstance(folium_map, folium.Map)
assert gdf.crs.to_epsg() == 4326, f"Expected EPSG:4326, got {gdf.crs.to_epsg()}"

# Features fall inside valid geographic bounds (lon -180..180, lat -90..90)
minx, miny, maxx, maxy = gdf.total_bounds
assert -180 <= minx <= maxx <= 180, "Longitude out of range — CRS not normalized"
assert -90 <= miny <= maxy <= 90, "Latitude out of range — CRS not normalized"

For Streamlit, drive the page with the headless app test harness and assert the map component mounted and emitted the keys you depend on:

python
from streamlit.testing.v1 import AppTest

at = AppTest.from_file("app.py").run()
assert not at.exception
# st_folium serializes returned_objects into session state under its key
assert "parcels_map" in at.session_state

To catch GeoDataFrame bloat before it reaches the browser, profile the in-memory footprint of the layer you are about to bind:

python
import sys
print(f"GeoDataFrame footprint: {gdf.memory_usage(deep=True).sum() / 1e6:.1f} MB")
print(f"Serialized GeoJSON size: {sys.getsizeof(gdf.to_json()) / 1e6:.1f} MB")

Jump to heading Troubleshooting

The map renders as a blank or gray grid with no tiles

The tile layer is missing or the tile request is being blocked. Open the browser network tab and look for 403 Forbidden or CORS errors on the {z}/{x}/{y} tile requests. External providers enforce rate limits and origin policies that break embedded dashboards. Verify the tiles parameter is set, and for production switch to a self-hosted tile server or a caching proxy rather than hammering a public endpoint.

Features appear in the ocean off Africa, or thousands of kilometres from where they belong

The GeoDataFrame was bound while still in a projected CRS (commonly EPSG:3857 Web Mercator metres or a national grid). Leaflet interprets those large metre values as degrees. Reproject with gdf.to_crs("EPSG:4326") before calling folium.GeoJson, and assert gdf.total_bounds falls within longitude −180…180 and latitude −90…90.

Click and draw events never reach Python (last_object_clicked is always None)

returned_objects was not configured, or the key you read does not match what st_folium emits. Pass an explicit returned_objects=["last_object_clicked", "all_drawings"] list and read those exact keys from the returned dict. Confirm a stable key= is set so the component keeps its identity across reruns.

A plugin's UI controls are missing even though the code ran without error

The plugin’s JavaScript is served from a CDN that the deployment network blocks, or the folium version is older than the plugin expects. Pin folium>=0.15.0, and in locked-down environments download the plugin assets and serve them from an internal static host.

The dashboard reloads the entire map on every interaction and feels sluggish

A full folium.Map is being rebuilt and re-serialized on each rerun. Give st_folium a stable key, cache the data load with @st.cache_data, and avoid mutating the map object in place — recreate it from a clean template only when the layer composition actually changes. The rerun mechanics behind this are detailed in Widget Lifecycle Management.

Jump to heading Performance considerations

Treat payload size as the primary budget. A GeoJSON layer stays smooth in the browser up to roughly 10–20 MB serialized; past that, simplify geometry with gdf.geometry.simplify(tolerance), tile the data, or move to the WebGL path. Because the reactive frameworks re-serialize the entire map on rerun, the cost of a render is dominated by the layer’s serialized size, not by how clever your callbacks are — so the highest-leverage optimization is caching the parsed, normalized GeoDataFrame so step 4 never repeats needlessly.

Cache-key design matters for spatial data: hash on the input path plus a content version or file mtime rather than on the GeoDataFrame object itself, since geometry objects do not hash deterministically. For sync-versus-async trade-offs, keep map assembly synchronous (it is CPU-light) but push tile and remote-data fetching into the async loaders covered in Async Data Loading Patterns so the UI thread never blocks on network I/O.

SymptomThreshold / signalMitigation
Sluggish pan/zoomGeoJSON > ~20 MB serializedSimplify geometry or tile the layer
Slow first paintReload + reproject every rerunCache the normalized GeoDataFrame
Frame drops on dense points> ~10,000 markersCluster, or move to the WebGL path
UI freezes during loadBlocking tile/remote fetchAsync data loading off the UI thread

Back to Spatial Component Integration & Interactive Maps