Clustering Map Markers with Folium
Dropping ten thousand individual folium.Marker objects onto a map freezes the browser — every marker is a DOM node, and Leaflet renders them all at once. This guide replaces raw markers with server-aware clustering so a dense point set stays interactive. It is aimed at anyone plotting incident reports, sensor networks, or store locations, and sits under Interactive Maps with Folium within Web Mapping & Interactive Visualization.
Why This Approach / What Goes Wrong
A folium.Marker becomes a real DOM element with an icon image and event handlers. Browsers handle a few hundred comfortably; a few thousand introduce lag, and tens of thousands hang the page on load. MarkerCluster (from the Leaflet.markercluster plugin Folium bundles) groups nearby points into a single count badge that splits apart as you zoom in, so only the visible, de-densified set renders. For very large point sets, you also want FastMarkerCluster, which skips per-marker Python objects entirely and passes raw coordinate arrays to JavaScript.
The naive alternative — plotting everything and hoping — produces a map that technically loads but is unusable, and an HTML file bloated with one <div> per point.
Prerequisites
geopandas>=0.14folium>=0.16(bundlesMarkerClusterandFastMarkerCluster)
conda install -c conda-forge "geopandas=0.14.*" "folium=0.16.*"
Step-by-Step Implementation
1. Load points and reproject to WGS84.
import geopandas as gpd
# incidents: ~40k point features, originally in EPSG:2154 (Lambert-93)
incidents = gpd.read_file("incidents.gpkg").to_crs(epsg=4326)
incidents = incidents[incidents.geometry.notnull() & ~incidents.geometry.is_empty]
2. Extract a plain [lat, lon] array. Folium wants latitude first.
coords = [[geom.y, geom.x] for geom in incidents.geometry]
3. For moderate counts, use MarkerCluster with real markers (keeps per-point popups).
import folium
from folium.plugins import MarkerCluster
center = incidents.geometry.union_all().centroid
incident_map = folium.Map(location=[center.y, center.x], zoom_start=11, tiles="CartoDB positron")
cluster = MarkerCluster(name="Incidents").add_to(incident_map)
for (lat, lon), label in zip(coords, incidents["category"]):
folium.Marker([lat, lon], tooltip=label).add_to(cluster)
folium.LayerControl().add_to(incident_map)
incident_map.save("incidents_clustered.html")
4. For large counts (>20k), use FastMarkerCluster — it serializes the array directly, no Python Marker objects.
import folium
from folium.plugins import FastMarkerCluster
fast_map = folium.Map(location=[center.y, center.x], zoom_start=11, tiles="CartoDB positron")
FastMarkerCluster(data=coords, name="Incidents").add_to(fast_map)
folium.LayerControl().add_to(fast_map)
fast_map.save("incidents_fast.html")
Verification
Check the point count survived the pipeline and the output stays light.
print(f"Plotted {len(coords)} points") # Plotted 39812 points
assert len(coords) == len(incidents)
import os
size_mb = os.path.getsize("incidents_fast.html") / 1e6
print(f"FastMarkerCluster output: {size_mb:.2f} MB") # FastMarkerCluster output: 2.31 MB
FastMarkerCluster output should be a fraction of the equivalent MarkerCluster file because it stores a compact coordinate array, not thousands of marker definitions.
Edge Cases & Debugging
- Page still hangs with
MarkerCluster. You have too many for the per-marker path; switch toFastMarkerCluster. - No popups with
FastMarkerCluster. It trades interactivity for speed — supply a JScallbackto attach popups, or pre-aggregate. - Clusters never split apart. Points share identical coordinates (e.g., all snapped to a city centroid); jitter them or aggregate by location.
NaN/empty geometries crash the loop. Filter withincidents.geometry.notnull() & ~incidents.geometry.is_emptybefore extracting coordinates.- Even clustered output is too heavy. Past hundreds of thousands of points, render server-side as vector tiles — see Generating PMTiles from GeoParquet.