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 client-side clustering so a dense point set stays interactive, and it is aimed at anyone plotting incident reports, sensor networks, or store locations from a GeoPandas layer. It 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 — an icon image, a positioned <div>, and its own event handlers. Browsers handle a few hundred comfortably; a few thousand introduce scroll and pan lag, and tens of thousands hang the page on load because Leaflet builds and lays out every node before the first paint. The saved HTML also balloons, since each marker serialises to its own block of JavaScript.
MarkerCluster (from the Leaflet.markercluster plugin that Folium bundles) attacks the interaction cost: it groups nearby points into a single count badge that splits apart as you zoom in, so only the visible, de-densified set is rendered at any moment. It keeps real per-point markers underneath, so popups, tooltips, and per-feature styling all still work — you simply defer the rendering of markers that aren't on screen. That makes it the right tool up to a few tens of thousands of points.
FastMarkerCluster attacks the construction cost as well. Instead of instantiating a Python Marker object per point, it serialises a raw [lat, lon] array straight to JavaScript and lets the browser build the marker-cluster tree. There are no per-marker Python objects and no per-marker HTML, so both the build time and the file size drop sharply. The trade-off is interactivity: without a JavaScript callback, points carry no popups or tooltips — you get fast clustering and nothing else.
The naive alternative — plotting every marker and hoping — produces a map that technically loads but is unusable, wrapped in an HTML file bloated with one <div> definition per point. Clustering is not a nicety here; past a few thousand markers it is the difference between a working map and a frozen tab.
It helps to know what the plugin actually does, because most of the confusing behaviour follows directly from the algorithm. Leaflet.markercluster does not run a statistical clustering method — there is no DBSCAN, no k-means, and no notion of density in the spatial clustering sense. It builds a screen-space grid: at insertion time it walks every point once per zoom level, projects it to pixel coordinates for that zoom, and merges it into an existing group if that group's centre is within maxClusterRadius pixels. The result is a pyramid of pre-computed cluster trees, one per zoom, held in browser memory. Three consequences fall out of that:
- Clusters are pixel-defined, not distance-defined. Two points 300 m apart cluster at zoom 10 and separate at zoom 15 without the data changing, and the same badge covers a much larger ground area near the poles than at the equator because of Mercator scaling.
- The build is O(points × zoom levels). Doubling the point count roughly doubles the freeze on load, which is why
chunkedLoadingexists — it spreads that build across animation frames instead of blocking the first paint. - Cluster membership is decided before any styling runs. You cannot cluster "only the high-severity incidents" by styling; you need separate cluster groups, covered in step 7.
Prerequisites
geopandas>=0.14folium>=0.16(bundles bothMarkerClusterandFastMarkerCluster)
conda install -c conda-forge "geopandas=0.14.*" "folium=0.16.*"
Step-by-Step Implementation
1. Load the points and reproject to WGS84. Folium and Leaflet only speak geographic EPSG:4326, so a projected source layer must be reprojected first. Do any distance- or density-based pre-aggregation before this step, while the data is still in a projected coordinate reference system — degrees in EPSG:4326 are not metric, so measuring in them is wrong.
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 — the opposite of the (x, y) = (lon, lat) axis order Shapely stores. Getting this backwards silently drops every point into the ocean off West Africa (the 0,0 null island), so it is worth an explicit swap rather than a bare tuple.
coords = [[geom.y, geom.x] for geom in incidents.geometry] # [lat, lon], Folium order
3. For moderate counts, use MarkerCluster with real markers. This keeps per-point popups and tooltips. Compute the map centre from the data rather than hard-coding a location, and use a light basemap so the coloured cluster badges read clearly.
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. Tune the clustering behaviour when the defaults feel wrong. MarkerCluster accepts a Leaflet options dict. maxClusterRadius controls how aggressively nearby points merge (smaller = more, tighter clusters); disableClusteringAtZoom stops clustering once you are zoomed in far enough to see individual points; and chunkedLoading builds the marker-cluster tree in animation-frame batches so the page stays responsive during construction on a large set.
cluster = MarkerCluster(
name="Incidents",
options={
"maxClusterRadius": 60, # px; default 80 — tighten to split dense areas
"disableClusteringAtZoom": 16, # show raw markers once zoomed in
"chunkedLoading": True, # batch the build; avoids a load-time freeze
},
).add_to(incident_map)
maxClusterRadius decides how eagerly badges merge, disableClusteringAtZoom retires them once individual points are legible, and chunkedLoading spreads the build across animation frames.5. For large counts (>20k), switch to FastMarkerCluster. It serialises the coordinate array directly, with no Python Marker objects in between.
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")
6. Restore popups on FastMarkerCluster with a JS callback. Because there are no Python markers, you attach behaviour with a small JavaScript function that runs per point. Pass a third value in each row and read it inside the callback.
from folium.plugins import FastMarkerCluster
# each row is [lat, lon, label]
rows = [[lat, lon, cat] for (lat, lon), cat in zip(coords, incidents["category"])]
callback = """
function (row) {
var marker = L.marker(new L.LatLng(row[0], row[1]));
marker.bindPopup(row[2]);
return marker;
}
"""
FastMarkerCluster(data=rows, callback=callback, name="Incidents").add_to(fast_map)
The callback is raw JavaScript injected into the page, so it runs in the browser with no Python available. Keep it to marker construction — anything that needs a lookup table should have that table baked into the row itself. Rows are plain lists, so a fourth element carrying a colour or an icon name costs a few bytes per point and keeps the callback a one-liner.
7. Split one cluster into toggleable subgroups. A single MarkerCluster clusters everything together, which is wrong when the reader wants to compare categories. FeatureGroupSubGroup attaches child groups to one parent cluster: the badges still merge across all categories at low zoom, but LayerControl can switch any subgroup off, and the parent recomputes its counts live.
import folium
from folium.plugins import MarkerCluster, FeatureGroupSubGroup
subgroup_map = folium.Map(location=[center.y, center.x], zoom_start=11, tiles="CartoDB positron")
parent = MarkerCluster(name="All incidents", control=False).add_to(subgroup_map)
for category in sorted(incidents["category"].unique()):
subgroup = FeatureGroupSubGroup(parent, name=category)
subgroup_map.add_child(subgroup)
subset = incidents[incidents["category"] == category]
for geom in subset.geometry:
folium.Marker([geom.y, geom.x], tooltip=category).add_to(subgroup)
folium.LayerControl(collapsed=False).add_to(subgroup_map)
subgroup_map.save("incidents_by_category.html")
control=False on the parent hides it from the layer control so the reader only sees the category checkboxes. Note that this path re-introduces per-point Python Marker objects, so it belongs on the MarkerCluster side of the size ceiling, not the FastMarkerCluster side.
8. When popups do not matter, drop the DOM entirely. folium.Marker is an icon-based DOM element; folium.CircleMarker is a vector path, and Leaflet can draw vector paths onto a single <canvas> element instead of one SVG node each. Constructing the map with prefer_canvas=True flips that renderer, which typically lets a browser hold tens of thousands of circles without clustering at all — useful when the point pattern itself is the message and individual identity is not.
import folium
canvas_map = folium.Map(
location=[center.y, center.x],
zoom_start=11,
tiles="CartoDB positron",
prefer_canvas=True, # one canvas, not one DOM node per feature
)
for geom in incidents.geometry:
folium.CircleMarker(
[geom.y, geom.x], radius=3, weight=0, fill=True,
fill_color="#0d6f87", fill_opacity=0.6,
).add_to(canvas_map)
canvas_map.save("incidents_canvas.html")
prefer_canvas affects vector layers only — it does nothing for folium.Marker, because icon markers are always DOM elements. That asymmetry catches people out: they set the flag, keep using Marker, measure no change, and conclude the option is broken. If the canvas path still stutters, the remaining cost is the per-circle JavaScript definition in the file, and the answer is clustering or tiles rather than another renderer flag.
Verification
Check that the point count survived the pipeline intact and that the output file stays light enough to embed or serve.
print(f"Plotted {len(coords)} points") # Plotted 39812 points
assert len(coords) == len(incidents), "Points dropped during coordinate extraction"
import os
size_mb = os.path.getsize("incidents_fast.html") / 1e6
print(f"FastMarkerCluster output: {size_mb:.2f} MB") # FastMarkerCluster output: 2.31 MB
assert size_mb < 5, "Too heavy to embed — move to vector tiles"
FastMarkerCluster output should be a fraction of the equivalent MarkerCluster file, because it stores one compact coordinate array rather than thousands of individual marker definitions. If the two files are similar in size, you are probably still building real Marker objects somewhere in the loop.
FastMarkerCluster replaces with one coordinate array.Edge Cases & Debugging
- Page still hangs with
MarkerCluster. You have too many points for the per-marker path; enablechunkedLoading, or switch toFastMarkerCluster. - No popups with
FastMarkerCluster. It trades interactivity for speed by default — supply a JScallback(step 6) to attach popups, or pre-aggregate the points server-side. - Every point lands off the coast of Africa. Latitude and longitude are swapped; Folium expects
[lat, lon]while Shapely stores(x, y)=(lon, lat). Fix the extraction, and see Coordinate Systems with PyProj for the axis-order rules behind it. - Clusters never split apart on zoom. Points share identical coordinates (e.g. all snapped to a city centroid); jitter them slightly or aggregate by location before plotting.
NaNor 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, stop shipping raw coordinates to the browser and render server-side as vector tiles — see Generating PMTiles from GeoParquet.
- Badge counts are lower than the row count. Markers were added to the map instead of to the
MarkerClusterobject —.add_to(incident_map)rather than.add_to(cluster)— so they render unclustered on top and never enter a badge. Rows with a null geometry drop out for the same reason one step earlier. - A hover outline covers half the city.
showCoverageOnHover(on by default) draws the convex hull of each badge's members; with one far-flung outlier that hull is enormous. Set it toFalse, or clean the outliers upstream. - Coincident points fan out into a spider instead of splitting. That is
spiderfyOnMaxZoomdoing its job at the deepest zoom, and it is the correct behaviour for genuinely stacked records such as several incidents at one address. SetdisableClusteringAtZoombelow the max zoom if you would rather see overlapping markers. FeatureGroupSubGrouprenders but the checkboxes do nothing. The subgroup was added to the parentMarkerClusterinstead of to the map. Subgroups take the parent as a constructor argument, then get added to theMapobject withadd_child.- Cluster badges look wrong against a dark basemap. The plugin ships fixed green/yellow/orange CSS classes. Pass an
icon_create_function(a JavaScript function returning a LeafletDivIcon) toMarkerClusterto control the badge markup and colours yourself.
Frequently Asked Questions
Should I reach for MarkerCluster or FastMarkerCluster first?
Start with MarkerCluster and only move when a measurement forces you to. MarkerCluster keeps real Python Marker objects, so tooltips, popups, per-feature icons and subgroups all work with no JavaScript. The costs it does not remove are build time and file size: every marker is still written into the HTML. FastMarkerCluster removes both, at the price of writing a JavaScript callback for anything beyond a plain dot. The practical trigger is the saved file — once incidents_clustered.html passes a few megabytes, or the page takes more than a second or two to become interactive, switch.
Is clustering the right answer, or should this be a heatmap?
Ask whether the reader will ever click a point. If individual records matter — an incident report they need to identify, a store they need the address of — cluster, because clustering preserves identity all the way down to the marker. If the question is purely "where is this concentrated", folium.plugins.HeatMap answers it with a fraction of the payload and no badge arithmetic to interpret. Cluster badges are also easy to misread as a density surface when they are not: a badge's position is the mean of its members, so a badge can sit in an empty field between two neighbourhoods.
Does clustering make the HTML file smaller?
MarkerCluster does not — it changes when markers render, not whether they are written. Every point still appears in the file as its own JavaScript definition, which is why a clustered map can feel snappy and still be an 8 MB download. Only three things actually shrink the payload: FastMarkerCluster's flat coordinate array, aggregating points upstream (by hex bin, by address, by administrative area), or moving to vector tiles so the coordinates never travel to the browser at all.
How do I pre-aggregate instead of clustering client-side?
Do the grouping in a projected CRS with GeoPandas and plot the summary. A dissolve or attribute aggregation collapses points to one marker per group with a count column you can drive marker radius from; a spatial join against a hex grid or a boundary layer gives the same thing on a regular tessellation. This trades interactivity for control — you decide the aggregation unit rather than letting pixel distance decide it — and it is the only approach whose cost is independent of how far the reader zooms out.
Can I colour cluster badges by an attribute of their members?
Not from Python directly. The badge is generated in the browser after membership is decided, so the only hook is icon_create_function, a JavaScript function that receives that group and returns a DivIcon. Inside it you can call cluster.getAllChildMarkers() and inspect whatever you stashed on each marker, then build the badge markup accordingly. If that feels like too much JavaScript for the payoff, the usual alternative is one FeatureGroupSubGroup per category with distinct marker colours, and let the reader toggle categories instead of reading a blended badge.
Why do the clusters change when I only changed the map size?
Because maxClusterRadius is measured in screen pixels. A map rendered in a narrow dashboard column shows the same ground area in fewer pixels, so points fall inside the radius that were outside it on a full-width page, and badges merge. If your map is embedded at a variable width — a Streamlit column, a responsive report — pick a radius that reads well at the narrowest layout, and verify at both extremes before publishing.