Using Tooltips with pydeck Without a Mapbox Token
Tooltips are a Deck.gl feature rendered in the browser from columns already in your layer — no token, no service, no round trip. The token question is only ever about the basemap, and three basemaps need none.
Jump to heading Why this matters
The most-copied pydeck example sets map_style="mapbox://styles/mapbox/light-v9", which needs a Mapbox account. Teams building internal tools hit this on day one, conclude that pydeck requires a commercial dependency, and either provision a token they do not need or abandon pydeck for a DOM renderer that will not survive their feature count.
The two things are unrelated. Deck.gl renders your layers on a GPU canvas and knows nothing about basemaps; a basemap is an optional image underneath, and there are several free ways to supply one. Tooltips are rendered from the records already sitting in the layer’s data, in the browser, with no external service involved at all — which is also why they are instant, and why the constraints on them are about what you shipped rather than about what you can fetch.
Jump to heading Prerequisites
pydeck>=0.9andstreamlit>=1.36, or Panel with a pydeck pane.- A layer whose payload has been trimmed as Deck.gl advanced layers describes — the pruning is what makes the tooltip’s column requirement a live concern rather than a theoretical one.
Jump to heading Step-by-step solution
Jump to heading Step 1 — Choose a basemap that needs nothing
import pydeck as pdk
# (a) A Carto basemap — a one-word change, no account.
deck = pdk.Deck(layers=[layer], map_style="dark", initial_view_state=view)
# (b) No basemap at all — correct when the layer covers the view.
deck = pdk.Deck(layers=[layer], map_style=None, initial_view_state=view)
# (c) Your own tiles, as a normal Deck.gl layer.
tiles = pdk.Layer(
"TileLayer",
data="https://tiles.internal.example/{z}/{x}/{y}.png",
min_zoom=0, max_zoom=19, tile_size=256,
)
deck = pdk.Deck(layers=[tiles, layer], initial_view_state=view)
Option © is the one worth knowing for an internal deployment. Because the basemap becomes a Deck.gl layer rather than a separate canvas underneath, it composites with everything else — so opacity, layer order and view state all behave as one system, and nothing on the page reaches for the public internet.
Jump to heading Step 2 — Ship the columns the template names
TOOLTIP_COLUMNS = ["name", "category", "score"]
frame = gdf[["longitude", "latitude", "color", "radius", *TOOLTIP_COLUMNS]]
layer = pdk.Layer(
"ScatterplotLayer",
data=frame.to_dict(orient="records"),
get_position=["longitude", "latitude"],
get_fill_color="color", get_radius="radius",
pickable=True, # without this, no tooltip ever appears
)
pickable=True is the switch people miss most often: a layer that is not pickable produces no hover events at all, so the tooltip simply never shows and there is nothing in the console to say why.
Keeping the tooltip’s columns in a named constant is worth the two lines. Payload trimming is an ongoing pressure — as the payload chart shows, dropping columns is the largest single reduction available — and a constant makes it obvious which three are load-bearing for the interface rather than merely present.
Jump to heading Step 3 — Escape the values, then template
import html
for column in TOOLTIP_COLUMNS:
if frame[column].dtype == object:
frame[column] = frame[column].map(lambda v: html.escape(str(v)))
deck = pdk.Deck(
layers=[tiles, layer],
initial_view_state=view,
tooltip={
"html": "<b>{name}</b><br/>{category} · score {score}",
"style": {"backgroundColor": "#2d1239", "color": "#fdf3ee",
"fontSize": "12px", "padding": "8px 10px", "borderRadius": "6px"},
},
)
The escaping is not optional for any attribute a person can edit. The template is interpolated into HTML in the browser and Deck.gl does not escape the values, so a place name containing a script tag executes in the session of everybody who hovers over it. Escaping server-side, before the frame is serialised, is the only place the distinction between markup and data is still known.
Jump to heading Step 4 — Understand what a tooltip may and may not do
The chart is the reason to resist the obvious next request — “can the tooltip show the record from the database?” It can, and it turns a free interaction into a queued round trip that arrives after the pointer has moved on. If the extra fields matter, they belong on a click-opened panel, where a two-hundred-millisecond fetch is invisible because the user has committed to looking at one thing.
Jump to heading Verification
# 1. Every templated column survived the payload trimming.
import re
named = set(re.findall(r"\{(\w+)\}", deck.tooltip["html"]))
assert named <= set(frame.columns), f"tooltip names missing columns: {named - set(frame.columns)}"
# 2. The layer is pickable, or nothing will ever appear.
assert layer.pickable is True
# 3. No unescaped markup reached the payload.
assert not frame["name"].astype(str).str.contains("<script", case=False).any()
The first check is worth running in CI. It costs nothing, and it catches the specific regression where somebody trims a column for performance and the tooltip starts rendering {category} as literal text on production — a failure with no exception, no console message and no test that would otherwise notice.
Jump to heading Edge cases and gotchas
- Nested values.
to_dict(orient="records")will happily embed a list or a dict, and the template renders it as a Python repr. Flatten to strings before shipping. - Nulls. A missing value renders as
nullorNonerather than as an empty string. Fill them deliberately — an empty tooltip line reads better than the word None. - Multiple layers. With several pickable layers the tooltip resolves against whichever is on top at the pointer. A single template referencing columns that exist in only one of them renders placeholders over the others; give each layer its own tooltip, or ensure the named columns exist in all of them.
- Very wide tooltips. The element is positioned relative to the pointer and is not constrained by the map, so a long value pushes it off-screen at the edges. Set a
maxWidthin the style dictionary.
Jump to heading FAQ
Does map_style=None break anything?
No — you get your layers on a transparent canvas over the page background, which is legitimate and sometimes better. It only looks wrong when the layer is sparse, because there is then no geographic context for the eye to place the points against. For a dense choropleth or a heatmap covering the view it is often the cleaner result.
Can I use a Markdown or component-based tooltip instead of HTML?
Not through Deck.gl’s tooltip, which takes an HTML string. If you need real components, render your own element positioned from the pick event rather than using the built-in tooltip — at which point you own the positioning, the escaping and the performance, all three of which the built-in one was handling.
Why is the tooltip slow on a large layer?
It is not the tooltip; it is picking. Deck.gl resolves a hover by rendering a picking pass, and on a layer with millions of features that pass costs something on every pointer move. Reduce it by making only the layers that need interaction pickable, and by lowering pickingRadius from its default.
Back to Deck.gl Advanced Layers.