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.
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?
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.
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.
Related
- Popups, Tooltips & Feature Interaction — the parent guide covering affordances and hit testing
- Hover Highlighting with MapLibre Feature State — the equivalent interaction in a vector-tile stack
- Toggling GeoJSON Overlays with Folium LayerControl — where the layer this popup belongs to is registered
- Setting Content-Security-Policy Headers for Embedded Folium Maps — the second line of defence when a map is embedded