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.

What a tooltip needs, and where each piece comes fromA pydeck tooltip is rendered by Deck.gl in the browser from fields already present in the layer's data, which is what makes it instant and what constrains it. The template is an HTML string with brace placeholders naming columns; anything not in the shipped columns cannot appear, so a tooltip referencing a field you pruned renders the literal placeholder text rather than failing. Styling is a dictionary of CSS applied to the tooltip element, and it is the only styling hook — there is no class to target from the page stylesheet, because the tooltip is created outside the document flow. Because the template is interpolated into HTML, any attribute value that can contain markup has to be escaped server-side before it is shipped; Deck.gl does not escape it for you, and a place name containing a script tag is a stored cross-site scripting vector.THE THREE PARTS OF A pydeck TOOLTIPhtml — brace placeholders naming columnsthe columns must be in the shipped data; a missing one renders as literal text rather than raisingstyle — a dict of CSSthe only styling hook, because the element sits outside the document and no stylesheet reaches itthe values — escaped before they shipinterpolated as HTML and not escaped for you, so an attribute containing markup is a stored XSS vectorNo Mapbox token is involved in any of this — tooltips are a Deck.gl feature, and the token question is only everabout the basemap underneath.

Jump to heading Prerequisites

  • pydeck>=0.9 and streamlit>=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

Where the basemap comes from without a tokenDeck.gl draws layers; it does not draw a basemap. The token question arises only because the most commonly copied example uses a Mapbox style, which needs one. Three alternatives need nothing. Passing map_style as a Carto style URL uses Carto's free basemaps and is a one-word change. Passing None omits the basemap entirely, which is the right answer when the layer covers the whole view and the geography underneath adds nothing. Adding a TileLayer pointed at any XYZ endpoint — an internal tile server, an open dataset, a self-hosted set — renders the basemap as just another Deck.gl layer, which also means it composites with the others rather than sitting behind an iframe.THREE BASEMAPS THAT NEED NO TOKENoptionsmap_style='dark'map_style=NoneTileLayer(XYZ url)The last is the one to reach for on an air-gapped network: an internal tile server becomes a normal layer, andnothing in the page asks the internet for anything.
python
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

python
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

python
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

What a tooltip costs, by where it is resolvedThe same hover is served three ways and the latency measured from pointer-enter to tooltip visible. Rendered by Deck.gl from columns already in the layer, it appears in about one millisecond and the server is never involved, so a pointer crossing forty features during a sweep costs nothing at all. Fetched from Python through a rerun, it takes about two hundred and forty milliseconds each, and because a sweep generates them faster than they complete they queue — so tooltips arrive for features the pointer left seconds ago. Fetched from a dedicated lightweight endpoint that bypasses the rerun, it lands around forty milliseconds, which is usable for a click-triggered panel and still far too slow for hover. The conclusion is not about optimisation: hover must be answered from data the client already holds, and anything that cannot be is a click interaction wearing the wrong trigger.POINTER-ENTER TO TOOLTIP VISIBLEDeck.gl, from shipped columns≈ 1 ms · the server never hears about itdedicated endpoint, no rerun≈ 40 ms · fine for a click, not for hoverthrough a Streamlit rerun≈ 240 ms each, andthey queue behind each otherA pointer crossing a dense layer generates these faster than the third option can answer, so the tooltips arrive forfeatures the analyst has already left — which reads as haunted rather than slow.

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

python
# 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 null or None rather 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 maxWidth in 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.