Exporting PyDeck Visualizations to Standalone HTML

Part of the Static vs Dynamic Export Methods guide.

Operative rule: Always set notebook_display=False when exporting pydeck maps outside a Jupyter kernel — the default True wraps output in an iframe scaffold that breaks standalone browser rendering.

How It Works

pydeck serializes your Python layer configuration — viewport state, data payloads, and Deck.gl constructor options — into a single .html file. That file bundles an inline JavaScript bootstrap that loads the Deck.gl WebGL runtime (from a CDN by default) and reconstructs the interactive map entirely in the browser. No Python process, no tile server, and no WebSocket connection is needed after the file is written.

The export mechanism sits squarely in the Static vs Dynamic Export Methods decision space: you trade real-time data mutability for a zero-infrastructure artifact that can be committed to a repository, uploaded to object storage, or dropped into an <iframe> inside responsive dashboard layouts. Because all data is inlined at export time, the map reflects the state of your DataFrame at the moment the script ran — fitting for nightly reports, cached snapshots, and scheduled map rebuild workflows that regenerate the file on a cron schedule.

The diagram below shows how Python data flows through pydeck into the standalone HTML artifact and then into the browser’s WebGL pipeline.

PyDeck standalone HTML export data-flow Four stages: Python DataFrame → pydeck.Deck serializer → standalone .html file → browser WebGL renderer. Arrows connect each stage left to right. Python DataFrame / GeoJSON pydeck.Deck serializer + to_html() Standalone .html artifact Browser WebGL / Deck.gl

Production-Ready Implementation

The script below is copy-ready for use in a CLI tool, CI step, or scheduled map rebuild workflow. It uses Python 3.10+ type hints, produces a deterministic filename with a content hash for cache busting, and wraps the export in a context that works identically in headless runners and local terminals.

from __future__ import annotations

import hashlib
import os
from pathlib import Path

import pandas as pd
import pydeck as pdk


def export_scatterplot_map(
    output_dir: Path,
    mapbox_key: str = "",
) -> Path:
    """
    Build a ScatterplotLayer map from sample data and export it to
    a standalone HTML file with a content-hash suffix.

    Args:
        output_dir: Directory where the .html file will be written.
        mapbox_key:  Mapbox public token. Pass "" to use a MapLibre
                     style URL instead (avoids Mapbox billing).

    Returns:
        Absolute path to the written HTML file.
    """
    # 1. Prepare geospatial data — coordinates must be EPSG:4326 (lon/lat).
    df = pd.DataFrame(
        {
            "lat": [40.7128, 34.0522, 41.8781, 29.7604, 47.6062],
            "lon": [-74.0060, -118.2437, -87.6298, -95.3698, -122.3321],
            "city": ["New York", "Los Angeles", "Chicago", "Houston", "Seattle"],
            "value": [120, 85, 200, 150, 95],
        }
    )

    # 2. Define the layer. get_position must list lon before lat.
    scatter_layer = pdk.Layer(
        "ScatterplotLayer",
        data=df.to_dict(orient="records"),
        get_position=["lon", "lat"],  # Deck.gl expects [longitude, latitude]
        get_radius="value",
        get_fill_color=[255, 140, 0, 180],
        pickable=True,
        radius_min_pixels=4,
        radius_max_pixels=24,
    )

    # 3. Choose a basemap style. Swap in a MapLibre URL to skip Mapbox auth.
    map_style = (
        "mapbox://styles/mapbox/light-v11"
        if mapbox_key
        else "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
    )

    # 4. Construct the Deck object.
    deck = pdk.Deck(
        layers=[scatter_layer],
        initial_view_state=pdk.ViewState(
            latitude=38.5,
            longitude=-96.0,
            zoom=3,
            pitch=0,
        ),
        map_style=map_style,
        mapbox_key=mapbox_key or None,
        tooltip={"text": "{city}: {value}"},
    )

    # 5. Export to HTML.
    #    notebook_display=False strips the Jupyter iframe wrapper — required
    #    for any context outside a running Jupyter kernel.
    html_string: str = deck.to_html(
        notebook_display=False,
        as_string=True,           # return string so we can hash it
        custom_css=(
            "body { margin: 0; padding: 0; }"
            " #deck-container { width: 100vw; height: 100vh; }"
        ),
    )

    # 6. Append a short content hash to the filename for cache busting.
    content_hash = hashlib.sha256(html_string.encode()).hexdigest()[:8]
    output_dir.mkdir(parents=True, exist_ok=True)
    output_path = output_dir / f"dashboard_{content_hash}.html"
    output_path.write_text(html_string, encoding="utf-8")

    return output_path.resolve()


if __name__ == "__main__":
    token = os.getenv("MAPBOX_TOKEN", "")
    path = export_scatterplot_map(
        output_dir=Path("dist"),
        mapbox_key=token,
    )
    print(f"Exported: {path}")

Alternative Variants

Embedding in an existing page via <iframe>

When the exported file is deployed to a static host, drop it into any HTML page with a standard iframe. Set allow="fullscreen" if your layer supports fullscreen toggling.

<iframe
  src="/maps/dashboard_a8f3c2.html"
  width="100%"
  height="600"
  frameborder="0"
  title="City value distribution map"
  allow="fullscreen">
</iframe>

For CORS and sandbox restrictions that affect this pattern, see the iframe embedding isolation guide.

Key to_html() parameter reference

Parameter Default When to change
notebook_display True Set False outside Jupyter — mandatory for browser-open files
as_string False Set True to post-process (hash, upload to S3) before writing
custom_css "" Override #deck-container margins for embedded or full-screen use
iframe_width / iframe_height "100%" / "500px" Tune for fixed-size dashboard panels in Jupyter
open_browser False Set True during local development to auto-open the file

Offline / air-gapped export: to_html() bakes a CDN <script> tag for the Deck.gl bundle into the output. For air-gapped CI environments, use as_string=True, retrieve the HTML, and replace the CDN <script src="..."> with a <script src="/static/deck.gl.min.js"> pointing to a locally hosted bundle. The Deck.gl release page publishes pre-built UMD bundles alongside each version tag.

Verification Steps

After running the export script, confirm the output is correct before deploying:

  • File exists and is non-empty: ls -lh dist/dashboard_*.html — expect 200 KB–2 MB depending on data payload size.
  • Deck.gl script tag present: grep -o 'unpkg.com/deck.gl' dist/dashboard_*.html should return a match (or your local bundle path for offline builds).
  • Browser smoke-test: Open the file directly with python -m http.server 8000 and navigate to http://localhost:8000/dist/dashboard_<hash>.html. The map should render, pan, zoom, and show tooltips on hover.
  • WebGL check: Open browser DevTools → Console. A WebGL context lost message indicates hardware acceleration is disabled; enable it in browser flags or test on a different machine.
  • Data size check: Open DevTools → Network, reload, and inspect the document size. If the .html response exceeds 5 MB, refactor to host data externally (see the “Inline Data Limits” note in the performance section below).

Common Errors & Fixes

AttributeError: 'Deck' object has no attribute 'to_html'

Root cause: pydeck version is below 0.8.0, where to_html() did not exist. Earlier versions required a notebook=True workaround via deck.show() in Jupyter.

Fix: Upgrade with pip install "pydeck>=0.8.0" and verify with python -c "import pydeck; print(pydeck.__version__)".

Exported file opens but the map canvas is blank (no tiles, no layers)

Root cause: Most common cause is a missing or invalid Mapbox token when using map_style="light" or "dark". Secondary cause: notebook_display=True left in place, producing an iframe-wrapped document that browsers refuse to render as a top-level page.

Fix: Pass a valid mapbox_key to pdk.Deck(), or switch map_style to a MapLibre-compatible URL (e.g. a Carto or ESRI public style). Also set notebook_display=False.

TypeError: Object of type ndarray is not JSON serializable

Root cause: NumPy arrays in the DataFrame were not converted to plain Python types before passing to pdk.Layer. The pydeck serializer does not automatically coerce numpy.float32, numpy.int64, or numpy.nan values.

Fix: Call df = df.astype({"value": float}).where(df.notna(), other=None) before df.to_dict(orient="records"). For GeoJSON geometry columns, extract coordinates as plain Python lists with .tolist().

CORS error when loading data from an external URL

Root cause: The Deck.gl runtime in the exported HTML fetches the data URL at render time. If that URL is on a different origin without Access-Control-Allow-Origin: *, the browser blocks the request.

Fix: Inline the data in the Python script (the default behaviour of df.to_dict(orient="records")), or ensure the data host sets appropriate CORS headers. For large datasets, place the data file on the same origin as the HTML file — e.g. the same S3 bucket with a public bucket policy.