Part of the Popups, Tooltips & Feature Interaction guide.
Operative rule: set pickable=True only on the layers a reader will actually interrogate — each pickable layer costs a full extra render pass, and the cost is paid on every pointer move whether or not anything is hit.
How the Picking Pass Works
Deck.gl does not test geometry to find what is under the cursor. It renders the scene a second time, off-screen, with every object painted in a unique colour derived from its index, then reads one pixel at the pointer’s position and decodes that colour back into a layer and a row number. The lookup is a single pixel read, so identifying one point among five million costs exactly what identifying one among five hundred costs.
What is not free is the extra pass. Every pickable layer is drawn twice per interaction frame, so making a decorative basemap overlay pickable doubles its cost for no benefit. The optimisation that matters is therefore not making picking faster but making fewer things pickable.
Production-Ready Implementation
from __future__ import annotations
import geopandas as gpd
import pandas as pd
import pydeck as pdk
# Only the columns the tooltip needs travel to the browser.
TOOLTIP_COLUMNS = ["site_id", "site_name", "reading", "recorded_at"]
def build_deck(gdf: gpd.GeoDataFrame) -> pdk.Deck:
"""A million-point scatterplot with a tooltip and one pickable layer."""
if gdf.crs is None or gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(epsg=4326)
df = pd.DataFrame({
"lon": gdf.geometry.x,
"lat": gdf.geometry.y,
**{c: gdf[c] for c in TOOLTIP_COLUMNS if c in gdf.columns},
})
points = pdk.Layer(
"ScatterplotLayer",
data=df,
get_position="[lon, lat]",
get_radius=40,
radius_min_pixels=1.5,
radius_max_pixels=6,
get_fill_color=[43, 140, 190, 180],
pickable=True, # the ONLY pickable layer
auto_highlight=True, # free hover feedback, no extra code
highlight_color=[255, 209, 102, 220],
)
context = pdk.Layer(
"GeoJsonLayer",
data="boundaries.geojson",
stroked=True,
filled=False,
get_line_color=[120, 120, 120, 120],
pickable=False, # decorative — never make it pickable
)
tooltip = {
"html": (
"<b>{site_name}</b><br/>"
"reading: {reading}<br/>"
"<span class='muted'>{recorded_at}</span>"
),
"style": {"backgroundColor": "#0D2B45", "color": "white",
"fontSize": "12px", "maxWidth": "260px"},
}
view = pdk.ViewState(
latitude=float(df["lat"].mean()),
longitude=float(df["lon"].mean()),
zoom=8,
)
return pdk.Deck(layers=[context, points], initial_view_state=view,
tooltip=tooltip, map_style="light")
if __name__ == "__main__":
sites = gpd.read_file("sensor_readings.geojson")
build_deck(sites).to_html("sensors.html", notebook_display=False)
Two choices there are worth naming. Building a narrow DataFrame with only the tooltip columns keeps the payload small — the whole dataset does not need to travel just because four of its fields are shown. And auto_highlight gives hover feedback without a single line of JavaScript, which on a million-point layer is both the cheapest and the most reliable option.
Keeping Picking Affordable
What a Tooltip Should Say at This Scale
At a million points, the tooltip is often the only way a reader can ask about an individual record — but it is also, at that density, the least likely thing they clicked deliberately. Design for both: keep it to the identity of the record plus the one value the map is about, and put anything longer behind a click that opens a panel.
A final note on expectations: at very high density a hover result is a plausible answer rather than a deliberate choice, because several points sit under the cursor and only one can win. Designing the tooltip to be informative rather than authoritative — and offering aggregation when density makes individual picking meaningless — keeps the interaction honest about what it can tell the reader.
Verification Steps
- Confirm
pickableisFalseon every decorative layer, then profile a pointer sweep before and after. - Hover the densest region and confirm the tooltip resolves within a frame.
- Remove a tooltip column from the DataFrame and confirm the template degrades to a blank rather than the word
undefined. - Confirm the exported HTML does not carry columns the tooltip never shows.
- Test on a machine without a discrete GPU: picking depends on the same context the render does.
Common Errors & Fixes
The tooltip never appears
pickable is unset on the layer. It defaults to false, and there is no warning — the layer simply never reports a hit.
Hovering shows a neighbouring point rather than the one under the cursor
The picking radius is larger than the spacing between points. Reduce it, or aggregate so that the reader picks a bin instead.
The tooltip flickers between two layers
Two layers are pickable and overlap. Make only the data layer pickable, or handle the hit list explicitly and take the topmost deterministically.
Everything works locally and the deployed page shows nothing
The data path in the exported HTML is relative to the notebook rather than to the deployment root — the same class of problem covered in exporting PyDeck visualizations to standalone HTML.
Working With the Payload, Not Against It
At a million rows the tooltip’s field list is also a payload decision. Every column named in the template travels to the browser for every row, whether or not a reader ever hovers that point — so a tooltip showing five fields on a million-row layer ships five million values to display, at most, a few dozen of them.
There are three ways to keep that honest. The first is simply to name fewer fields: an identifier, the mapped value and a timestamp are usually enough, with everything else behind a click. The second is to shorten the values themselves — an integer code rather than a long category string, a Unix timestamp rather than an ISO string — and expand them in the template. The third, for genuinely large attribute sets, is to ship only an identifier with the points and fetch the full record on demand when a reader clicks, which turns a per-row cost into a per-interaction one.
Column types matter as much as column count. A string column is stored as individual JavaScript strings; a numeric column becomes a compact typed array. Converting a categorical column to a small integer code before export can halve the payload of a large layer on its own, and the decode table costs a few dozen bytes.
It is also worth being deliberate about precision. Coordinates exported at fifteen decimal places carry no information a screen can show, and they inflate every row. Rounding to six decimals — about ten centimetres — before the frame leaves Python is invisible on the map and measurably smaller on the wire, particularly once the file is compressed, because repeated short values compress far better than long unique ones.
Gotchas & Edge Cases
- The picking pass encodes object indices as colours, so an enormous single layer can exhaust the index space; splitting into several layers is the remedy, not reducing the data.
- Tooltip templates read the row object directly, so a column renamed during export produces the literal placeholder text rather than an error.
auto_highlightallocates its own buffers; on a memory-constrained device it can be the difference between a layer that renders and one that does not.- Picking respects layer order, so a large transparent layer drawn above the points will absorb every hover unless it is marked unpickable.
- On a high-density display the picking buffer is allocated at device resolution, which multiplies its memory cost by four — worth knowing when a layer works on a laptop and fails on a tablet.
- A tooltip that appears near the viewport edge should flip rather than be clipped; deck.gl does not do this for you, and a clipped tooltip is indistinguishable from a broken one.
Related
- Popups, Tooltips & Feature Interaction — the parent guide covering affordances and hit testing
- Rendering a Million Points with PyDeck ScatterplotLayer — the layer this tooltip sits on
- Building a Custom Layer Switcher in PyDeck — toggling layers without losing picking state
- Exporting PyDeck Visualizations to Standalone HTML — shipping the result