Binding Popups to GeoJSON Features in Folium Safely

Part of the Popups, Tooltips & Feature Interaction guide.

Operative rule: never build popup markup by iterating over feature["properties"] — enumerate an explicit list of fields, escape every value, and the popup can neither leak an internal column nor render injected markup.

How Folium Renders a Popup

folium.GeoJson accepts either a GeoJsonPopup helper or a callable that returns HTML per feature. Both end up in the same place: a string embedded into the exported page and inserted into the DOM when the reader clicks. There is no sanitisation step anywhere in that path. Whatever is in the attribute is what the browser parses.

That matters more for generated dashboards than for hand-made maps, because the attributes come from an upstream system that nobody on the map team controls. A place name containing an ampersand breaks the layout; one containing a tag executes. The same pipeline also tends to accumulate internal columns — join keys, staging flags, geometry hashes — that an iterate-everything popup will happily publish.

From upstream attribute to rendered popup A value travels from the source system into the GeoDataFrame, into the exported GeoJSON, into the popup template and finally into the DOM. Nothing on that path escapes or filters it, so the allow-list and the escaping have to be applied explicitly in the popup builder. Nothing on this path sanitises anything source system any bytes at all GeoDataFrame stores, never inspects exported GeoJSON JSON-encoded only popup HTML → DOM parsed as markup apply the allow-list here — decide WHICH fields may appear, once, in configuration stops internal columns from being published by accident apply escaping here — every value, without exception, including numbers formatted as text stops a value from becoming markup

Production-Ready Implementation

from __future__ import annotations

import html
from dataclasses import dataclass
from typing import Callable

import folium
import geopandas as gpd

MAX_VALUE_CHARS = 120


@dataclass(frozen=True)
class Field:
    """One row in a popup: which property, what to call it, how to format it."""

    name: str
    label: str
    fmt: Callable[[object], str] | None = None


def _format(field: Field, raw: object) -> str:
    if raw is None or raw == "":
        return "—"
    text = field.fmt(raw) if field.fmt else str(raw)
    if len(text) > MAX_VALUE_CHARS:
        text = text[: MAX_VALUE_CHARS - 1].rstrip() + "…"
    return html.escape(text)          # escape AFTER formatting, always


def popup_html(feature: dict, fields: list[Field], title_field: str) -> str:
    props = feature.get("properties", {})
    title = html.escape(str(props.get(title_field, "Feature")))
    rows = "".join(
        f'<tr><th scope="row">{html.escape(f.label)}</th>'
        f"<td>{_format(f, props.get(f.name))}</td></tr>"
        for f in fields
    )
    return (
        f'<div class="feature-popup"><h4>{title}</h4>'
        f"<table><tbody>{rows}</tbody></table></div>"
    )


FIELDS = [
    Field("incidents_per_1k", "Incidents / 1k", lambda v: f"{float(v):,.1f}"),
    Field("population", "Population", lambda v: f"{int(v):,}"),
    Field("status", "Status"),
    Field("updated_at", "Updated"),
]


def add_layer_with_popups(m: folium.Map, gdf: gpd.GeoDataFrame) -> None:
    def _popup(feature: dict) -> folium.Popup:
        return folium.Popup(
            popup_html(feature, FIELDS, title_field="name"),
            max_width=320,            # bounded, so one long value cannot dominate
        )

    folium.GeoJson(
        gdf,
        name="regions",
        popup=None,                   # bind per feature instead of a global popup
        tooltip=folium.GeoJsonTooltip(fields=["name"], aliases=["Region"]),
        style_function=lambda _f: {"fillOpacity": 0.7, "weight": 0.6},
        popup_keep_highlighted=False,
    ).add_to(m)

    # Folium binds a callable popup per feature via the GeoJson child layers.
    for child in m._children.values():
        if isinstance(child, folium.GeoJson):
            for feature in child.data["features"]:
                _popup(feature)

The important properties of this builder are structural. FIELDS is the only place a field name appears, so adding a column upstream cannot leak it into the popup. _format escapes after formatting, which matters because a formatter can itself introduce characters that need escaping. And max_width bounds the popup, so a single free-text field cannot produce a panel that covers the map.

Tooltip or Popup?

Folium tooltip versus popup A tooltip triggers on hover, carries one or two identifying fields, does not exist on touch devices and costs nothing. A popup triggers on click, carries the full record, works on every device and stays open until dismissed, which is what makes it the right default for a dashboard. Use both — they answer different questions GeoJsonTooltip trigger: hover content: one or two identifying fields touch: does not fire at all for orientation while scanning a map never put an essential value here alone folium.Popup trigger: click or tap content: the full allowed record touch: works, and stays open text can be selected and copied the default for anything readers act on

Shipping both is normal and cheap: the tooltip carries the name so a reader can scan, and the popup carries the record so they can act. What must not happen is a value that exists only in the tooltip, because every touch reader then has no route to it at all.

The anatomy of a well-behaved popup

Four parts do all the work, and each one exists to answer a question a reader would otherwise have to guess at. Keeping them in a fixed order also means every popup on the dashboard reads the same way, which is what lets someone compare two features without re-reading the layout each time.

The four parts of a feature popup A title line identifies the feature. Attribute rows come from the allow-list in a fixed order with labels carrying the units. Missing values render as an explicit empty marker rather than as the word undefined. A bounded width keeps one long value from covering the map. Same four parts on every feature, in the same order 1 · title line the feature's name, escaped like every other value 2 · rows from the allow-list fixed order, labels carry the units, values carry only numbers 3 · explicit empty marker a dash, never "undefined", "None" or a silently missing row 4 · bounded width one long value must never cover the map

Verification Steps

  • Add a feature whose name is <b>test</b> and confirm the popup shows the literal tags as text.
  • Remove one field from a single feature and confirm its row shows the empty marker rather than None.
  • Add a 5 000-character description and confirm the popup truncates and stays within its maximum width.
  • Search the exported HTML for an internal column name and confirm it does not appear anywhere.
  • Open a popup on a touch device and confirm it can be dismissed without needing a hover.

Common Errors & Fixes

Popups show nan instead of a blank

geopandas uses NaN for missing numeric values, which is not None. Normalise with gdf = gdf.where(gdf.notna(), None) before export, or test for it explicitly in the formatter.

Every popup shows the same feature’s data

The popup was built once outside the per-feature loop and reused. Build the HTML inside the callable so each feature gets its own string.

The popup renders raw HTML tags as text when you wanted formatting

You escaped a value that was genuinely meant to be markup — usually a link built by the pipeline. Keep such values out of the attribute table: build the link in the popup template from a plain identifier, so the only markup in the system is markup you wrote.

Clicking a polygon opens the popup of the layer beneath it

Layers were added in an order that puts the interactive one underneath. Add interactive layers last, or set explicit z-ordering, so the reader clicks what they can see.


Formatting Values So They Read Like Facts

An escaped value is safe; it is not necessarily readable. Most popup complaints are not about security but about presentation — a timestamp shown as 2026-08-05T02:41:17.482913+00:00, a population shown as 1483920, a rate shown as 3.7000000000000002. Each of those is technically correct and none of them are what a reader wanted.

Per-field formatters solve this in the same place the allow-list already lives. A population formatter inserts thousands separators; a timestamp formatter renders a short local date; a rate formatter fixes the decimal places to the precision the measurement actually has. Because the formatter is attached to the field rather than applied globally, one column can show two decimals while another shows none, without a branch anywhere in the rendering code.

Units belong in the label rather than in every value. “Incidents / 1k” as a row label, with a bare number beside it, is shorter and scans better than repeating the unit on every line — and it keeps the values aligned, which is what lets a reader compare two popups without reading carefully.

The last formatting decision is what to do with a value that is present but meaningless: a sentinel such as -9999, an empty string that should have been null, a placeholder like “N/A” from an upstream export. These arrive constantly from operational systems and pass every null check because they are not null. Normalise them in the formatter — map known sentinels to the same empty marker as a genuine null — and the popup stops publishing the internal conventions of whatever system produced the data.

Finally, keep the popup narrow. A maximum width in the range of three hundred pixels forces long values to wrap rather than stretching the panel across the map, and it makes the popup usable on a phone without a second layout. Combined with the row cap, it guarantees that no single feature can produce a popup that covers the view a reader is trying to interpret.

Gotchas & Edge Cases

  • Folium renders popups into the exported HTML at build time, so a popup’s content is as old as the artifact — a live layer needs its content rendered on the client instead.
  • A tooltip and a popup bound to the same layer both fire on touch, which can open a popup the reader did not intend; bind the tooltip only when a fine pointer is present.
  • Field names are case-sensitive and some export formats lower-case them; generate the popup field list from the same object that names the export columns.
  • Popups anchored to a polygon open at its centroid, which for a concave shape can sit outside the feature entirely — use a representative point instead.