Toggling GeoJSON Overlays with Folium LayerControl

Part of the Layer Management & Toggling guide.

Operative rule: Add folium.LayerControl() last, after every overlay is on the map — it captures only the layers that already exist when it is created, so anything added afterward will be missing from the toggle.

How Folium LayerControl Works

Folium models a map as a tree of children. Each tile layer, folium.FeatureGroup, and folium.GeoJson you add becomes a named child of the map, and each carries two flags that decide how it behaves in the switcher: overlay (checkbox versus radio) and show (initially visible or not). folium.LayerControl is not a container you register layers into; it is a Leaflet control that, at the moment you add it, walks the map’s existing children and builds a toggle entry for every one that declares a name. This is why ordering is load-bearing — the control is a snapshot, not a live subscription. Add an overlay after the control and the layer renders on the map but never appears in the switcher, because the snapshot was already taken. This single ordering constraint is the heart of Layer Management & Toggling in Folium.

The two flags compose cleanly. overlay=True makes the layer a checkbox so multiple data layers can be visible simultaneously; overlay=False places it in the base-layer group as a radio button, where selecting one deselects the rest — the right model for mutually exclusive base maps. show=False registers the layer but leaves it switched off on load, which keeps a busy dashboard legible while still offering the data a click away. When the overlays come from a geopandas GeoDataFrame, you group rows by an attribute, build one named FeatureGroup per group, and add each GeoJson into its group — a pattern that pairs naturally with safely embedding the resulting Folium map in React dashboards. Because PyDeck has no equivalent built-in, the same requirement drives a hand-rolled solution when building a custom layer switcher in PyDeck.

LayerControl snapshots existing layers Three named overlays are added to the map in sequence. LayerControl, added last, snapshots them into a toggle list. An overlay added after the control is missing from the list. Map children (in add order) FeatureGroup "Roads" (show=True) FeatureGroup "Zones" (show=False) GeoJson "Incidents" (overlay=True) LayerControl() added LAST snapshots children above FeatureGroup added AFTER control — missing from the toggle Rendered toggle [x] Roads [ ] Zones [x] Incidents (late layer absent)

Production-Ready Implementation

The script loads a geopandas GeoDataFrame, splits it into named overlays by a category column, styles each group, and — critically — adds folium.LayerControl() only after the loop has registered every group. Base tiles are added with overlay=False so they behave as radio-selectable base maps rather than checkboxes.

from __future__ import annotations

import folium
import geopandas as gpd

# Distinct fill colour per category; keys must match the "category" values.
CATEGORY_STYLE: dict[str, str] = {
    "residential": "#4e79a7",
    "commercial": "#59a14f",
    "industrial": "#e15759",
}


def build_toggleable_map(gdf: gpd.GeoDataFrame) -> folium.Map:
    """Build a Folium map with one toggleable overlay per category."""
    # LayerControl and GeoJson both require EPSG:4326 (WGS84 lon/lat).
    gdf = gdf.to_crs("EPSG:4326")

    centroid = gdf.geometry.union_all().centroid
    m = folium.Map(location=[centroid.y, centroid.x], zoom_start=12, tiles=None)

    # Base layers: overlay=False -> radio buttons in the base-layer group.
    folium.TileLayer("CartoDB positron", name="Light base", overlay=False).add_to(m)
    folium.TileLayer("CartoDB dark_matter", name="Dark base", overlay=False,
                     show=False).add_to(m)

    # One named FeatureGroup per category. Build ALL of them before the control.
    for category, group in gdf.groupby("category"):
        colour = CATEGORY_STYLE.get(category, "#888888")
        # show=False on industrial keeps the initial view uncluttered.
        fg = folium.FeatureGroup(
            name=f"{category.title()} parcels",
            overlay=True,                 # checkbox -> multiple can be visible
            show=(category != "industrial"),
        )
        folium.GeoJson(
            group.to_json(),
            style_function=lambda _feat, c=colour: {
                "fillColor": c,
                "color": c,
                "weight": 1,
                "fillOpacity": 0.4,
            },
            tooltip=folium.GeoJsonTooltip(fields=["name", "category"]),
        ).add_to(fg)
        fg.add_to(m)

    # OPERATIVE RULE: add the control LAST, after every overlay exists.
    folium.LayerControl(collapsed=False).add_to(m)
    return m


if __name__ == "__main__":
    gdf = gpd.read_file("parcels.geojson")   # must contain a "category" column
    build_toggleable_map(gdf).save("parcels_map.html")

Alternative Variants

Grouping overlays with GroupedLayerControl

When several overlays are mutually exclusive within a theme — pick exactly one demographic layer at a time — the plugin folium.plugins.GroupedLayerControl renders radio groups over FeatureGroups. The groups must reference layers already added to the map, so the same last-added ordering rule applies.

from folium.plugins import GroupedLayerControl

pop = folium.FeatureGroup(name="Population", show=True).add_to(m)
income = folium.FeatureGroup(name="Median income", show=False).add_to(m)

# Radio group: only one of these two overlays is visible at a time.
GroupedLayerControl(
    groups={"Demographic layer": [pop, income]},
    exclusive_groups=True,
    collapsed=False,
).add_to(m)   # still added after the FeatureGroups it references

Flag reference for overlays

Argument Effect Typical use
name="…" Label shown in the control Every toggleable layer needs one
overlay=True Checkbox; stackable Data overlays (parcels, incidents)
overlay=False Radio in base group Mutually exclusive base tiles
show=True Visible on load Primary layer
show=False Registered but off Secondary/heavy layers
control=False Rendered, hidden from control Always-on context layers

Verification Steps

  • Every overlay appears: open the saved .html and confirm the control lists each named layer; a missing entry almost always means the layer was added after LayerControl.
  • Initial state matches flags: confirm show=False layers load unchecked and show=True layers load checked.
  • Base layers are exclusive: confirm the base tiles behave as radio buttons — selecting one hides the other — while data overlays remain independent checkboxes.
  • Toggling redraws cleanly: switch each overlay off and on and confirm features disappear and reappear without stale geometry or console errors.
  • CRS is correct: confirm overlays align with the base tiles; misalignment signals the GeoDataFrame was not reprojected to EPSG:4326.

Common Errors & Fixes

LayerControl is empty or missing layers

folium.LayerControl() was added before some (or all) overlays, so its snapshot captured nothing or only part of the tree. Fix: move the folium.LayerControl() call to the very end of the build, after every FeatureGroup and GeoJson has been added. This is the operative rule; there is no argument that makes the control retroactively pick up later layers.

Overlays render but cannot be turned off

The layers were added to the map without a name, so LayerControl skipped them even though they draw. An unnamed layer is invisible to the control by design. Fix: pass an explicit name="…" to each FeatureGroup, GeoJson, or TileLayer you want in the toggle, and add data features inside a named FeatureGroup rather than directly on the map.

Base maps show as checkboxes and can all be switched off at once

The tile layers were added with the default overlay=True, so they behave as independent overlays and the user can hide every base map, leaving a blank canvas. Fix: add base tiles with overlay=False so they join the radio-selectable base group, where exactly one is always active.

GeoJson overlay is offset from the base tiles

The source GeoDataFrame is in a projected CRS (a UTM zone or national grid) rather than WGS84, so Leaflet places the features in the wrong location. Fix: call gdf.to_crs("EPSG:4326") before building any GeoJson, since Leaflet and the GeoJSON spec both assume EPSG:4326 lon/lat.