pydeck

render_pydeck is deprecated. Bring your pydeck.Layer objects to maplibre (or lonboard, for scale) instead.

pydeck is the Python binding for deck.gl, the WebGL layer catalog behind many striking data maps (hexagon bins, arcs, heatmaps, 3D columns). For years, pydeck.Deck was a Jupyter widget you could render live, and shinywidgets shipped @render_pydeck to bring that into Shiny.

That stopped working with pydeck 0.9 (2024): pydeck dropped its Jupyter widget entirely. Deck.show() now returns static HTML instead of a widget, and Deck.update() raises. No ipywidgets bridge – shinywidgets included – can render a live pydeck.Deck on pydeck >= 0.9, so render_pydeck is deprecated and raises an explanatory error instead of a blank map.

The good news: pydeck’s actual value – the declarative pydeck.Layer API over deck.gl’s layer catalog – still works great in Shiny. This guide is a migration guide, not a feature tour: it shows the error you’ll hit, then the two supported ways to bring your existing pdk.Layer objects into a reactive Shiny app (a maplibre map, or lonboard for very large data), plus a static fallback for one-off maps. For the full feature set of either destination renderer, see their own guides; this page only covers the pydeck-specific migration path.

hero

Why it’s deprecated, and what you’ll see

pydeck’s has_jupyter_extra() / DeckGLWidget machinery still exists in pydeck >= 0.9, but nothing ever populates it – it’s a leftover from the ipywidgets-7 era. Calling @render_pydeck and returning a pydeck.Deck raises a TypeError up front, rather than rendering a blank or broken map.

examples/01_deprecation_error.py is what porting a notebook cell straight into Shiny looks like – and what happens when you run it:

import pydeck as pdk
from shiny.express import ui
from shinywidgets import render_pydeck

ui.page_opts(fillable=True)

BIKE_RACKS = "https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/sf-bike-parking.json"


with ui.card(full_screen=True):

    @render_pydeck
    def bike_racks():
        layer = pdk.Layer(
            "ScatterplotLayer",
            BIKE_RACKS,
            get_position="COORDINATES",
            get_radius="SPACES",
            get_fill_color=[255, 140, 0],
            pickable=True,
            radius_scale=6,
        )
        view_state = pdk.ViewState(
            latitude=37.7749, longitude=-122.4194, zoom=11
        )
        return pdk.Deck(layers=[layer], initial_view_state=view_state)

deprecation error

The card shows the error inline because Shiny’s dev-mode default is sanitize_errors=False – the app shows the real exception, not just a generic message. The full text (also always in the server log):

pydeck v0.9 removed its Jupyter widget (Deck.show() now returns static HTML), so shinywidgets can’t render pydeck maps. To use pydeck layers in a reactive map, pass pydeck.Layer objects to a maplibre Map’s add_deck_layers() and render it with @render_maplibre (pip install 'shinywidgets[maplibre]'). For large data, consider lonboard and @render_lonboard. For a static map, use shiny’s @render.ui decorator and return deck.show() (…). For more, see https://github.com/visgl/deck.gl/issues/9064

One thing to know if you instrument your own app: render_pydeck(...) also issues a FutureWarning at decoration time – when the @render_pydeck line itself runs, not when the output renders. Verified in a real shiny run: it prints to the server log by default, pointing at the exact @render_pydeck line (stacklevel=2), with no flags needed:

app.py:12: FutureWarning: render_pydeck is deprecated: pydeck v0.9 removed its Jupyter widget, so it
can't render maps. Pass pydeck.Layer objects to a maplibre Map's add_deck_layers() and use
@render_maplibre instead (see help(shinywidgets.render_pydeck) for other alternatives).
  @render_pydeck

It’s a FutureWarning, not a DeprecationWarning, specifically so this shows up: Python’s default warning filters hide DeprecationWarning outside the __main__ module, and a Shiny app’s module is never __main__. The TypeError above still fires when the output actually renders a pydeck.Deck; the warning is the earlier, decoration-time signal, and current main makes sure you’ll actually see both.

Migration path B: lonboard (large data)

If your pydeck map’s bottleneck was data volume rather than deck.gl’s layer catalog, convert to lonboard instead of maplibre. lonboard sends your geometry once as binary Arrow/GeoArrow data and restyles with no data re-transfer at all, whereas every set_deck_layers() call on a maplibre map re-sends the full layer JSON (fine up to a few hundred thousand rows; see the maplibre guide’s gallery showcase for a 140k-point example of that trade-off). lonboard also has real reactive accessors (view_state(), clicked(), selected(), selected_bounds()), not just a reactive_read() tier, and a built-in box-select – useful once an app needs more than click/pan.

The trade-off: lonboard has no GPU-side aggregation layers (no Hexagon/Grid/Heatmap binning) – aggregate in Python first (e.g. with H3) if you were relying on one of those.

pip install lonboard geopandas pyarrow

Converting the same ScatterplotLayer means building a GeoDataFrame (lonboard’s ScatterplotLayer.from_geopandas() wants real point geometry, not a get_position= column name) and passing per-row styling as plain arrays instead of pydeck’s string accessors:

import geopandas as gpd
import pandas as pd
import requests
from lonboard import Map, ScatterplotLayer
from shiny.express import ui
from shinywidgets import render_lonboard

BIKE_RACKS = requests.get(
    "https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/sf-bike-parking.json"
).json()
df = pd.DataFrame(BIKE_RACKS)
LNG = df["COORDINATES"].str[0]
LAT = df["COORDINATES"].str[1]
RACKS = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(LNG, LAT), crs="EPSG:4326")

ui.page_opts(fillable=True)

with ui.card(full_screen=True):

    @render_lonboard
    def bike_racks():
        return Map(
            ScatterplotLayer.from_geopandas(
                RACKS,
                get_radius=RACKS["SPACES"],
                radius_scale=6,
                radius_min_pixels=1,
                get_fill_color=[255, 140, 0],
                pickable=True,
            )
        )

lonboard scatter

get_position="COORDINATES" (a pydeck string accessor into each row) became the GeoDataFrame’s geometry column; get_radius="SPACES" became a plain Series passed by value. Map(...) auto-fits the view to the data, same as pydeck’s initial_view_state would have needed you to compute by hand.

Migration path C: static map (@render.ui + to_html)

If you don’t need reactivity at all – a one-off map that never updates after it’s drawn – you can keep using pydeck’s own HTML export, wrapped in an iframe:

import pydeck as pdk
import requests
from shiny.express import render, ui

BIKE_RACKS = requests.get(
    "https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/sf-bike-parking.json"
).json()

ui.page_opts(fillable=True)

with ui.card(full_screen=True):

    @render.ui
    def bike_racks():
        layer = pdk.Layer(
            "ScatterplotLayer",
            BIKE_RACKS,
            get_position="COORDINATES",
            get_radius="SPACES",
            get_fill_color=[255, 140, 0],
            radius_scale=6,
            radius_min_pixels=1,
        )
        view_state = pdk.ViewState(latitude=37.7749, longitude=-122.4194, zoom=12)
        deck = pdk.Deck(layers=[layer], initial_view_state=view_state)
        # deck.to_html() with no filename returns None -- only as_string=True
        # gives you the HTML to embed. There's no reactivity here: every input
        # change re-renders this whole function and re-sends the full page.
        return ui.tags.iframe(
            srcdoc=deck.to_html(as_string=True),
            style="border:0;width:100%;height:100%;flex:1 1 auto",
        )

static iframe

Limits, verified on this 2,520-row dataset: the embedded page is 0.48 MB and gets re-sent on every reactive invalidation of the render function, even ones that don’t touch the map – there’s no restyle-in-place here, no camera preservation, and no events back to Python. At pydeck’s own 140,000-point UK-accidents scale that was 15.6 MB resent per change (see the old evaluation report); use this path only for maps that render once and never react to anything.

Mapping table: pydeck -> maplibre / lonboard

pydeck concept maplibre equivalent lonboard equivalent
pdk.Deck(layers=[...]) Map(...) + m.add_deck_layers(layers) lonboard.Map(layers) (native lonboard layers, not pdk.Layer)
pdk.Layer("ScatterplotLayer"/"HexagonLayer"/..., data, get_*=...) passed through unchanged to add_deck_layers() re-expressed as a native lonboard layer (ScatterplotLayer.from_geopandas(), etc.) + Arrow/GeoArrow data
pdk.ViewState(latitude=, longitude=, zoom=, pitch=, bearing=) MapOptions(center=(lng, lat), zoom=, pitch=, bearing=) – note the lng-first tuple Map(view_state=...), auto-fit by default
deck.update() map.widget.set_deck_layers([...]) from a @reactive.effect set a layer trait directly (e.g. map.widget.layers[0].get_fill_color = ...) from a @reactive.effect
on_click / on_hover / on_view_state_change callbacks map.read("clicked" / "view_state") (no accessor; a documented shortcut for reactive_read(map.widget, ...)) map.clicked(), map.view_state(), map.selected(), map.selected_bounds() accessors
tooltip={"html": ...} add_deck_layers(layers, tooltip={...}) (same mustache-template dict) no built-in tooltip; build one from clicked()/hover state yourself
map_style / map_provider MapOptions(style=...), maplibre.basemaps.Carto / OpenFreeMap Map(basemap=...)
views=[...] (multiple/orbit/orthographic viewports) not supported (one MapLibre map per output) not supported
deck.show() / deck.to_html() n/a (use @render_maplibre instead) n/a (use @render_lonboard instead)

Gotchas and limitations

  • The TypeError only fires on render, not at decoration time. @render_pydeck itself succeeds (it just emits the FutureWarning described above, which current main makes sure actually shows up in the server log); the error only appears once Shiny actually tries to render the output. A module-level pdk.Deck() built outside any render function does not raise.
  • clicked is a location, not a picked feature, on both migration targets. Neither maplibre’s clicked trait nor pydeck’s old on_click callback (which did give you the picked object) are equivalent here – maplibre only reports where you clicked. Find the nearest row in Python, as both examples/03_view_state_clicked.py and the maplibre guide’s click-to-detail pattern do. lonboard’s selected() accessor, by contrast, does give you the picked row index directly – one more reason to prefer it if per-feature picking matters more than deck.gl’s aggregation layers.
  • deck.to_html() with no filename returns None, not a string. Easy to copy from pydeck’s own docs and get a blank output; you need as_string=True.
  • Every set_deck_layers() call re-sends the layer’s data as JSON. Fine at the 2,520-row scale in this guide’s examples; keep an eye on payload size as your pydeck migration’s row count grows, or switch to lonboard.
  • This renderer has no shinywidgets extra. Unlike every other render_* decorator, pip install 'shinywidgets[pydeck]' doesn’t exist – there’s nothing left to install for a renderer that only raises. Install pydeck (for the Layer API) plus shinywidgets[maplibre] or lonboard for wherever you’re rendering it.

Learn more

  • pydeck / deck.gl Python bindings documentation (the pydeck.Layer catalog and its per-layer keyword arguments are still the right reference once your layers live on a maplibre or lonboard map)
  • deck.gl/visgl#9064 – the open upstream issue tracking a possible revival of pydeck’s Jupyter/anywidget bindings
  • Related guides in this roster: maplibre (the primary migration target, full feature tour and accessor reference), lonboard (large-scale data, native accessors)