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:

Choosing a marker-plotting strategy by point count As the number of points grows, the right Folium strategy changes and each step removes a different cost. Hundreds of points use plain folium.Marker with no clustering. Thousands to tens of thousands use MarkerCluster, which removes the DOM render cost by deferring off-screen markers while keeping popups. Above roughly twenty thousand points with optional popups, FastMarkerCluster removes the per-point Python-object construction and HTML payload weight by serialising a raw coordinate array. Beyond hundreds of thousands of points, server-side vector tiles such as PMTiles remove the need to ship raw coordinates to the browser at all. Which marker strategy? Keyed on point count POINT COUNT GROWS STRATEGY COST IT REMOVES Hundreds folium.Marker plain per-point markers Nothing to remove — Leaflet renders a few hundred nodes comfortably Thousands – tens of thousands MarkerCluster real markers, popups kept Removes DOM render cost: off-screen markers are deferred > ~20,000 popups optional FastMarkerCluster raw array to JavaScript Removes Python-object build and per-marker HTML payload Hundreds of thousands + Vector tiles (PMTiles) rendered server-side Removes shipping raw coordinates to the browser
Each rung up the point count changes the strategy and removes a different bottleneck: render cost, then Python-object construction, then the payload itself.

Prerequisites

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)
How the tuning options reshape the same 3,058 incidents across zoom levels Three map panels show the same 3,058 incident points at increasing zoom. At zoom 10 two large badges hold 2,140 and 918 points. At zoom 13 the badges have split into five smaller ones holding 940, 744, 615, 410 and 349 points, because the pixel distance between groups now exceeds the maxClusterRadius of 60 pixels. At zoom 16 disableClusteringAtZoom has retired the badges entirely and fourteen individual markers are drawn. A key underneath explains the three Leaflet options: maxClusterRadius controls how eagerly nearby points merge, disableClusteringAtZoom sets the level at which raw markers appear, and chunkedLoading builds the marker-cluster tree in animation-frame batches so the page never freezes on load. The same 3,058 incidents, three zoom levels zoom 10 zoom 13 zoom 16 2,140 918 940 744 615 410 349 2 badges · 3,058 points 5 badges · 60 px radius exceeded clustering off · real markers OPTIONS THAT SHAPE THIS SEQUENCE maxClusterRadius: 60 smaller radius → more, tighter badges at every zoom disableClusteringAtZoom: 16 past zoom 16 Leaflet drops the badges entirely chunkedLoading: True builds the tree in animation- frame batches, no load freeze
The three options act at different moments: 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.

Byte composition of the MarkerCluster and FastMarkerCluster output files Two horizontal bars, drawn to scale, break down what fills each saved HTML file for the same 39,812 incidents. The MarkerCluster file is 8.90 megabytes: 0.42 for the Leaflet and markercluster JavaScript, 0.18 for the map scaffold, and 8.30 for per-marker definitions, one div and event handler per point. The FastMarkerCluster file is 2.31 megabytes: the same 0.42 and 0.18 of library and scaffold, then just 1.71 megabytes holding a single flat array of latitude and longitude pairs. The 6.59 megabyte difference is per-marker HTML that is simply never written. Where the bytes go in each saved file incidents_clustered.html · MarkerCluster · 8.90 MB 39,812 marker definitions one positioned div plus its own handlers, per point incidents_fast.html · FastMarkerCluster · 2.31 MB one flat array 6.59 MB of per-marker HTML never written same 39,812 points, same clustering behaviour Leaflet + markercluster JS (0.42 MB) map scaffold (0.18 MB) per-marker definitions coordinate array Bar length is proportional to file size on disk
Both files carry the same library and scaffold; the entire difference is the per-marker HTML that FastMarkerCluster replaces with one coordinate array.

Edge Cases & Debugging

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.