32 min read

> "A static chart shows you the answer. An interactive chart lets you ask the question."

Learning Objectives

  • Explain Plotly's architecture: Figure objects, traces, layouts, and the JSON-based rendering pipeline
  • Create interactive charts with Plotly Express across all standard chart types
  • Encode multiple variables via color, size, symbol, facet_row, facet_col, and animation_frame
  • Customize hover information with hover_name, hover_data, and hovertemplate
  • Add range sliders, dropdown selectors, and animation controls to Plotly charts
  • Export Plotly figures to static images and interactive HTML
  • Compare Plotly Express with matplotlib and seaborn on interactivity, styling, and export trade-offs

Chapter 20: Plotly Express — Interactive Charts in One Line of Code

"A static chart shows you the answer. An interactive chart lets you ask the question." — attributed to Jeff Heer, Stanford Visualization Group


20.1 Leaving the Page Behind

For nineteen chapters, every chart in this book has been a static image. Matplotlib produces static PNGs and PDFs. Seaborn wraps matplotlib and also produces static output. The conventions of Parts I and II — typography, color, data-ink ratio, small multiples — were developed for print, and they remain valid for any chart that will be printed, emailed, or pasted into a slide deck.

But the medium is changing. More charts today are viewed on screens than on paper. More are embedded in web pages than in PDFs. More are scrolled through in Jupyter notebooks and dashboards than examined in isolation. And on a screen, a chart does not have to be static. It can respond to the cursor. It can zoom. It can filter. It can animate through time. A chart on a screen has affordances that a chart on paper does not, and ignoring those affordances is like writing a novel that does not use any verbs — technically possible, needlessly constrained.

This chapter introduces Plotly Express, the simplest way to produce interactive charts in Python. Plotly Express sits on top of a larger library called Plotly (or more formally, plotly.py), which is itself a Python wrapper around a JavaScript rendering library called plotly.js. When you call a Plotly Express function, you are building a Python data structure that describes a chart. When you display that data structure, Python serializes it to JSON and hands it to plotly.js, which renders the chart in the browser using SVG and Canvas. The user can hover, zoom, click, and drag. The chart responds in real time.

This architectural split — Python describes, JavaScript renders — is the most important thing to understand about Plotly. It explains both the library's strengths (rich interactivity out of the box, no imperative drawing commands) and its weaknesses (large output files, limited typographic control, dependence on a web browser for rendering). You are not drawing on a canvas the way matplotlib does. You are building a JSON spec that gets executed by a separate piece of software.

Plotly Express specifically is a high-level convenience layer, introduced in 2019, that lets you produce most standard chart types with a single function call. Before Plotly Express existed, building a Plotly chart required manually constructing trace objects and layouts — powerful but verbose. Plotly Express mimics the design philosophy of seaborn: you pass a DataFrame and column names, and the library figures out the details. The result is an API that feels like seaborn but produces interactive output.

The chapter's threshold concept is that interactivity is not decorative. It implements Shneiderman's mantra — overview, zoom and filter, details on demand — at the level of a single chart. A well-designed interactive chart can replace an entire dashboard of static charts, because the reader can drill down without you having to pre-compute every possible view. This is the shift from "static images" to "interactive explorations," and it reframes what a chart is for.

20.2 Plotly's Place in the Ecosystem

Plotly was founded as a company in 2013 by a group of engineers who wanted to make interactive visualization accessible to non-programmers. The first products were a web-based chart editor (plot.ly, the original domain) and SaaS tools for data scientists and business users. The Python library, plotly.py, was released as open source in 2015, and it has been the company's most successful open-source product.

Plotly's position in the ecosystem sits between two extremes. On one side is matplotlib, which has existed since 2003 and is the foundation of everything static in Python. Matplotlib is mature, extensively documented, and tightly integrated with the scientific Python stack. It is the right tool when the output is a PDF or PNG and interactivity is optional. On the other side is bokeh, another Python interactive library, and dash / streamlit, which are dashboard frameworks. Bokeh is Plotly's closest competitor — both produce interactive web-native charts, both use JavaScript rendering, both have Python APIs. Bokeh is older (2012) and has a loyal following, but Plotly has captured the larger market share in recent years, especially after Plotly Express simplified the entry point.

Plotly's strengths are:

  • Rich interactivity by default. Every Plotly chart comes with hover tooltips, zoom, pan, and a toolbar. You do not need to enable these features; they are on from the moment you create the figure.
  • Web-native rendering. The charts render in any browser without Python installed on the viewer's side. You can email a Plotly chart as an HTML file and the recipient can interact with it.
  • Consistent API across chart types. The parameter names and patterns are similar across px.scatter, px.line, px.bar, and the other Plotly Express functions. Learning one chart type teaches you most of the others.
  • Integration with Dash. Plotly figures are the native chart type for the Dash web framework (Chapter 30), making Plotly the default for Python dashboard builders.

The weaknesses matter too:

  • Large output files. A Plotly chart embedded in HTML includes the full plotly.js library (several megabytes) unless you use CDN mode. Even with CDN, the JSON spec itself is much larger than a PNG or SVG.
  • Less typographic control. Plotly uses web fonts and has fewer options than matplotlib for precise control over spacing, kerning, and character placement. For publication-quality print output, matplotlib is still better.
  • Fewer statistical overlays. Plotly supports regression lines and confidence intervals, but the statistical ecosystem is less rich than seaborn's.
  • Dependence on a browser. Charts can be exported as static images (via kaleido), but the primary rendering path is always through a browser or notebook.

For the rest of this chapter, we treat Plotly Express as the entry point. Chapter 21 goes deeper into Plotly Graph Objects, where you can build anything Plotly Express cannot express.

20.3 Architecture: Figures, Traces, and Layouts

Every Plotly chart is built from the same three pieces, and understanding these pieces now will save you confusion later.

A Figure is the top-level container. It is a Python object of type plotly.graph_objects.Figure, and internally it is essentially a dictionary with two important keys: data (a list of traces) and layout (a layout object). When you call a Plotly Express function, the return value is a Figure. When you display it (by calling .show() or just returning it from a Jupyter cell), the Figure is serialized to JSON and sent to plotly.js for rendering.

A trace is one visual element — typically one "layer" of the chart. A line chart has one trace per line. A scatter plot has one trace per category (if you use color grouping) or one trace total (if you do not). A stacked bar chart has one trace per stack level. Each trace has a type (scatter, bar, heatmap, etc.) and a set of data arrays (x, y, marker.color, etc.). Multiple traces in the same figure share the axes but are otherwise independent.

The layout is everything about the figure that is not a trace: the title, the axis labels, the grid, the legend, the margins, the background color, the interactive controls like range sliders and buttons. Layout is where all the chart's "chrome" lives.

A minimal Plotly figure in Graph Objects looks like this:

import plotly.graph_objects as go

fig = go.Figure(
    data=[go.Scatter(x=[1, 2, 3], y=[1, 4, 9], mode="lines+markers")],
    layout=go.Layout(title="y = x²"),
)
fig.show()

The data is a list containing one trace (a Scatter trace with mode="lines+markers"). The layout contains the title. Calling fig.show() serializes the figure and displays it in the notebook or browser.

Plotly Express hides this structure behind a single function call. The equivalent is:

import plotly.express as px
import pandas as pd

df = pd.DataFrame({"x": [1, 2, 3], "y": [1, 4, 9]})
fig = px.line(df, x="x", y="y", title="y = x²", markers=True)
fig.show()

The Plotly Express version produces the same figure — same JSON structure, same traces, same layout — but you did not have to write any of the scaffolding. This pattern will recur: Plotly Express is a convenience layer, and every Plotly Express figure can be modified after the fact through the Graph Objects API (fig.update_traces, fig.update_layout, fig.add_trace).

20.4 The Canonical Example: px.scatter

The simplest Plotly Express function is px.scatter, and it is the right place to start because scatter plots exercise most of the library's features. Consider a classic dataset: the gapminder data on country-level life expectancy, GDP, and population, which Plotly ships as px.data.gapminder().

import plotly.express as px

gapminder = px.data.gapminder()
fig = px.scatter(
    gapminder.query("year == 2007"),
    x="gdpPercap",
    y="lifeExp",
    color="continent",
    size="pop",
    hover_name="country",
    log_x=True,
    size_max=60,
)
fig.show()

This single call produces a chart with several encodings at once:

  • x-axis: GDP per capita, log-scaled.
  • y-axis: life expectancy.
  • color: continent, with a categorical palette.
  • size: population, with size_max=60 capping the largest bubble.
  • hover: the country name appears when you mouse over a bubble, with GDP, life expectancy, continent, and population shown in the tooltip.

The result is a bubble chart that replicates Hans Rosling's famous Gapminder visualization (Chapter 9's first case study), and you can immediately interact with it. Hovering over a dot shows the country; clicking a legend entry isolates or hides that continent; dragging a rectangle zooms in; double-clicking reset the view. None of this interactivity required extra code. It is the default behavior of every Plotly chart.

Compare this to the matplotlib version of the same chart. In matplotlib, you would call ax.scatter(x, y, c=continent_codes, s=population_scaled), then build a colorbar manually, add a legend with custom handles for the continents, set ax.set_xscale("log"), add hover tooltips via mplcursors or a custom event handler, and spend ten or twenty lines producing what Plotly Express gives you in one. The static output of matplotlib is still beautiful, and for print use it remains the correct choice. But for screen use, the Plotly Express version is faster to build, richer to explore, and easier to share.

The px.scatter signature accepts dozens of parameters, most of which are optional. The most common ones are:

  • x, y — column names or arrays
  • color — categorical or continuous variable for color encoding
  • size — numeric variable for marker size
  • symbol — categorical variable for marker shape
  • hover_name — the "title" of the hover tooltip
  • hover_data — additional columns to show in the tooltip
  • facet_col, facet_row — columns for faceting into small multiples
  • log_x, log_y — boolean flags for log scales
  • title, labels — chart title and axis label overrides
  • template — styling template (see Section 20.11)

Every parameter has a reasonable default, and omitting a parameter produces whatever Plotly Express judges most appropriate. This is the seaborn philosophy (sensible defaults, progressive customization) applied to interactive output.

20.5 The Standard Chart Families

Plotly Express provides functions for essentially every standard chart type. Once you know px.scatter, the rest are variations on the same API.

Relational charts:

px.scatter(df, x="x", y="y", color="group")       # scatter
px.line(df, x="x", y="y", color="group")          # line chart
px.area(df, x="x", y="y", color="group")          # stacked area

Distributional charts:

px.histogram(df, x="value", color="group", barmode="overlay")
px.box(df, x="group", y="value", points="all")
px.violin(df, x="group", y="value", box=True)
px.ecdf(df, x="value", color="group")
px.strip(df, x="group", y="value")

Categorical charts:

px.bar(df, x="group", y="count", color="subgroup", barmode="group")
px.bar(df, x="group", y="count", color="subgroup", barmode="stack")

Part-to-whole charts:

px.pie(df, values="share", names="category")
px.treemap(df, path=["region", "country"], values="population")
px.sunburst(df, path=["region", "country", "city"], values="population")
px.funnel(df, x="count", y="stage")

Specialized charts:

px.density_heatmap(df, x="x", y="y", nbinsx=50, nbinsy=50)
px.density_contour(df, x="x", y="y")
px.scatter_matrix(df, dimensions=["col1", "col2", "col3"], color="group")  # pair plot
px.parallel_coordinates(df, color="group")
px.parallel_categories(df, dimensions=["cat1", "cat2", "cat3"])

The scatter matrix (px.scatter_matrix) is Plotly's equivalent of seaborn's pair plot. The parallel coordinates plot is a specialized multi-variable visualization where each row is a line that passes through one axis per variable — useful for comparing records across many dimensions. The parallel categories plot is similar but for categorical data, producing a flow-diagram-like layout.

Each of these functions follows the same pattern: pass a DataFrame, name the columns to use, and let Plotly Express handle the rest. Learning one function teaches you most of the others. The parameter name color always means "map this column to the color encoding." The parameter name facet_col always means "create small multiples in columns." The consistency is a feature — you can pick up a new chart type in minutes.

20.6 Encoding Multiple Variables

A chart with x, y, color, size, and symbol already encodes five variables — more than most printed charts ever show. Plotly Express makes these multi-encoding charts trivial, which creates a temptation that the practitioner must resist: just because you can encode five variables does not mean you should.

The design principles from Chapters 2 and 5 still apply. Each visual channel has a perceptual cost, and readers can only attend to so many encodings before the chart becomes noise. A good rule of thumb is: start with x and y, add color if grouping is important, add size if magnitude is important, and stop. Beyond three or four encoded variables, the chart becomes a puzzle rather than a communication.

That said, when the extra dimensions genuinely matter, Plotly Express makes them easy. Here is a five-encoding scatter:

fig = px.scatter(
    gapminder.query("year == 2007"),
    x="gdpPercap",
    y="lifeExp",
    color="continent",
    size="pop",
    symbol="continent",
    log_x=True,
    size_max=60,
    hover_name="country",
    labels={"gdpPercap": "GDP per capita (USD)", "lifeExp": "Life Expectancy (years)"},
)

The labels dictionary lets you rename any column for display purposes without modifying the DataFrame. This is a small convenience that matters a lot in practice — you rarely want to show a reader a column name like gdpPercap.

Faceting works the same way as in seaborn:

fig = px.scatter(
    gapminder,
    x="gdpPercap",
    y="lifeExp",
    color="continent",
    size="pop",
    facet_col="year",
    facet_col_wrap=4,
    log_x=True,
)

The facet_col="year" parameter produces one panel per year, arranged into rows of four. The shared axes mean you can compare panels directly. This is small multiples with interactive features — each panel has its own hover, zoom, and pan.

The animation_frame parameter is where Plotly Express becomes distinctive:

fig = px.scatter(
    gapminder,
    x="gdpPercap",
    y="lifeExp",
    color="continent",
    size="pop",
    hover_name="country",
    animation_frame="year",
    animation_group="country",
    log_x=True,
    size_max=60,
    range_x=[100, 100000],
    range_y=[25, 90],
)

This produces an animated bubble chart that plays through the years. The animation_group="country" parameter tells Plotly to keep each country as a single animated object that moves smoothly between frames, rather than creating new bubbles each frame. Setting range_x and range_y explicitly prevents the axes from rescaling as the data changes, which would disorient the viewer. The result is essentially the Hans Rosling Gapminder demo — the same chart that Rosling built custom for his TED talks in the mid-2000s, now a one-line Python call.

Animation is powerful, but it is also easily abused. A chart that animates through irrelevant frames is annoying at best and misleading at worst (change blindness, Chapter 2, still applies). Use animation when time is actually the story — when the reader wants to see how a pattern evolves — and use small multiples or interactive filtering when the reader just wants to compare different states.

20.7 Customizing Hover Information

The default hover tooltip shows whatever columns Plotly Express judged most relevant. This is usually good enough, but when you want control, there are three levels of customization.

Level 1: hover_name and hover_data. hover_name is the prominent text at the top of the tooltip. hover_data is a dict or list specifying which columns to include and how to format them. To show the country name as the title and add population, GDP, and life expectancy:

fig = px.scatter(
    gapminder.query("year == 2007"),
    x="gdpPercap",
    y="lifeExp",
    hover_name="country",
    hover_data={"pop": ":,", "gdpPercap": ":.0f", "lifeExp": ":.1f", "continent": True},
)

The ":," format string adds thousands separators; ":.0f" shows no decimals; ":.1f" shows one decimal. The True for continent means "include this column with default formatting." The False value would suppress a column from the hover.

Level 2: Custom hovertemplate. For full control, write a template string with Plotly's substitution syntax:

fig.update_traces(
    hovertemplate=(
        "<b>%{customdata[0]}</b><br>"
        "GDP per capita: $%{x:,.0f}<br>"
        "Life expectancy: %{y:.1f} years<br>"
        "Population: %{customdata[1]:,}<br>"
        "<extra></extra>"
    ),
    customdata=gapminder.query("year == 2007")[["country", "pop"]].values,
)

The %{x} and %{y} substitutions refer to the trace's x and y data. The %{customdata[N]} substitutions refer to extra per-point data you pass via the customdata parameter. The <br> tags produce line breaks. The <extra></extra> tags hide the "trace 0" label that Plotly shows by default. This is the most flexible way to format a tooltip, and it is the only way to include data that is not already an encoding on the chart.

Level 3: Disable hover. For some charts — especially dense ones or ones designed for print — you might want to turn hover off entirely. The parameter is hoverinfo='skip' on the trace, or fig.update_traces(hoverinfo='skip') after creating the figure.

The hover tooltip is arguably Plotly's most valuable feature over static charts. A matplotlib scatter plot shows you the points; a Plotly scatter plot lets you interrogate each point for its full record. The information density is higher without any visual cost. When you build interactive charts, designing a useful hover tooltip is part of the design work, not an afterthought.

20.8 Range Sliders and Selectors

A range slider is a secondary axis below a time-series chart that lets the reader drag a window to zoom in on a specific time period. It is the standard affordance for time series exploration in Plotly and most interactive visualization libraries. Adding one to a Plotly Express chart is a single call:

fig = px.line(gapminder.query("country == 'United States'"), x="year", y="lifeExp")
fig.update_layout(xaxis_rangeslider_visible=True)

The slider appears below the main chart. Dragging its endpoints narrows the visible range. Dragging the slider itself pans. Double-clicking resets. For time series with many points, this is a much better interaction than zooming with the mouse, because the user keeps the overview context in the slider while examining details above.

A similar affordance is the range selector, which provides preset buttons ("1y", "5y", "All"):

fig.update_layout(
    xaxis=dict(
        rangeselector=dict(
            buttons=[
                dict(count=10, label="10y", step="year", stepmode="backward"),
                dict(count=25, label="25y", step="year", stepmode="backward"),
                dict(step="all", label="All"),
            ]
        ),
        rangeslider=dict(visible=True),
    )
)

This combines both affordances: preset buttons for common ranges and a draggable slider for custom ranges. Financial charts use this pattern heavily; Yahoo Finance and Google Finance both show it, and users know how to interact with it.

Range sliders are a concrete example of Shneiderman's mantra in action. The main chart is the overview. The slider is the zoom-and-filter control. The hover on the zoomed chart gives details on demand. All three levels of Shneiderman's workflow live in a single compact display. This is the chapter's threshold concept in its simplest form: one interactive chart replaces what would otherwise be three static charts (a big overview, a zoomed view, and a table of individual values).

20.9 Animation Controls

Setting animation_frame="year" in a Plotly Express call produces an animated chart with a play button and a frame slider at the bottom. The user can play, pause, or drag the slider to a specific frame. This is a built-in affordance — you do not write a separate animation loop.

A few things are worth knowing about Plotly animation:

Sort the frames. If your animation_frame values are not in the order you want, sort the DataFrame first. Plotly animates frames in the order they appear.

Use animation_group for object continuity. When the same entity appears across frames (a country across years, a customer across quarters), set animation_group to the column that identifies the entity. Plotly will interpolate the entity between frames, producing smooth motion. Without animation_group, each frame is treated as independent points and the animation looks like a slideshow rather than motion.

Fix the axis ranges. Auto-scaling during animation is disorienting. Always set range_x and range_y explicitly when using animation_frame.

Animation is a storytelling tool. Use it when time is part of the story. For comparison across states, small multiples or a dropdown are usually better. For a dataset where "how things changed" is the main question, animation communicates more than a static chart.

20.10 Styling and Templates

Plotly has a template system — similar to matplotlib's style sheets (Chapter 12) — that controls the default colors, fonts, backgrounds, and other styling choices across all charts in a project. Setting a template is a one-line call:

import plotly.io as pio
pio.templates.default = "simple_white"

Once set, every subsequent figure uses the template. The built-in templates include "plotly" (the default), "plotly_white", "plotly_dark", "ggplot2", "seaborn", "simple_white", "presentation", "xgridoff", "ygridoff", "gridon", and "none". The "simple_white" template is a good default for publication-style output — clean white background, minimal grid, thin axes.

To override the template for one specific figure without changing the default, pass template="..." to the Plotly Express call:

fig = px.scatter(df, x="x", y="y", template="simple_white")

For finer styling control — color palettes, margins, fonts, specific axis properties — use fig.update_layout:

fig.update_layout(
    font=dict(family="Helvetica, Arial, sans-serif", size=14, color="#333"),
    margin=dict(l=60, r=30, t=60, b=60),
    plot_bgcolor="white",
    xaxis=dict(gridcolor="#eee", zeroline=False),
    yaxis=dict(gridcolor="#eee", zeroline=False),
)

Plotly Express accepts two color-specific arguments: color_discrete_sequence (a list of colors for categorical encodings) and color_continuous_scale (a colormap name or list for continuous encodings). Both accept either a built-in name or a list of hex codes:

px.scatter(df, x="x", y="y", color="category", color_discrete_sequence=px.colors.qualitative.Pastel)
px.scatter(df, x="x", y="y", color="value", color_continuous_scale="viridis")

Plotly ships with many built-in palettes accessed via px.colors.qualitative.* (for categorical) and px.colors.sequential.* / px.colors.diverging.* / px.colors.cyclical.* (for continuous). The same color principles from Chapter 3 apply: use sequential for ordered data, diverging for data with a meaningful midpoint, qualitative for categorical. Plotly's defaults are decent but not always optimal; it is worth setting a good default palette once at the start of a project and not thinking about it again.

20.11 Exporting Plotly Figures

A Plotly figure lives natively in a browser, but you will often need it as a static image or an HTML file.

Interactive HTML. The simplest export is to HTML:

import plotly.io as pio
pio.write_html(fig, "chart.html", include_plotlyjs="cdn")

The include_plotlyjs="cdn" argument tells Plotly to reference the plotly.js library from a content delivery network rather than embedding it in the file. This keeps the file small (a few tens of kilobytes) but requires an internet connection to display. If you need a fully offline file, use include_plotlyjs=True, which embeds the library (producing a file several megabytes in size).

Static images. For PNG, SVG, or PDF output, Plotly uses a separate library called kaleido. Install it with pip install kaleido, then:

pio.write_image(fig, "chart.png", width=1200, height=800, scale=2)
pio.write_image(fig, "chart.svg")
pio.write_image(fig, "chart.pdf")

The scale=2 parameter produces high-DPI output (twice the specified pixel dimensions), which looks sharp on retina displays. SVG output is vector and scales perfectly but is large for data-dense charts. PDF output is suitable for inclusion in LaTeX documents or print workflows.

Kaleido is the modern replacement for an older tool called orca, which required a separate Node.js installation. Kaleido is pip-installable and handles the rendering internally. The static export produces exactly what the interactive chart looks like at rest — no hover, no animation, no controls — making it a viable fallback for print use.

JSON. For serializing a figure to disk or transferring between processes:

import json
json_str = fig.to_json()
with open("chart.json", "w") as f:
    f.write(json_str)

# Later:
from plotly.io import from_json
with open("chart.json") as f:
    fig_loaded = from_json(f.read())

This is useful for caching expensive computations (you can compute a chart once and serialize it) and for debugging (you can inspect the JSON to see exactly what Plotly is about to render).

20.12 Progressive Project: Interactive Climate Chart

We return to the climate dataset for its first interactive treatment. The dataset has year, temperature anomaly, CO2, sea level, and an era label, same as previous chapters. The question: what does the climate plot look like when we can hover, zoom, animate, and filter?

Step 1 is a plain interactive line chart:

import plotly.express as px

fig = px.line(
    climate,
    x="year",
    y="temperature_anomaly",
    title="Global Temperature Anomaly, 1880-2024",
    labels={"year": "Year", "temperature_anomaly": "Anomaly (°C)"},
    template="simple_white",
)
fig.show()

This is a direct translation of Chapter 12's finished climate chart into Plotly. It looks similar, but now it is interactive: hovering over any point shows the exact year and value; dragging selects a range; double-clicking resets. This is already more useful than the matplotlib version — the reader can answer questions like "what was the anomaly in 1998?" without squinting at the axis labels.

Step 2 adds hover customization and a range slider:

fig = px.line(
    climate,
    x="year",
    y="temperature_anomaly",
    title="Global Temperature Anomaly, 1880-2024",
    labels={"year": "Year", "temperature_anomaly": "Anomaly (°C)"},
    hover_data={"year": ":d", "temperature_anomaly": ":.2f", "co2_ppm": ":.1f", "era": True},
    template="simple_white",
)
fig.update_layout(xaxis_rangeslider_visible=True)

Now the hover shows the year, the temperature anomaly, the CO2 concentration (even though CO2 is not on the chart's axes), and the era. The range slider lets the reader zoom into any sub-period — the 1900s, the post-1980 acceleration, the last decade. This is one interactive chart doing the work of three or four static ones.

Step 3 uses animation to show the multi-variable story:

climate["decade"] = (climate["year"] // 10) * 10
fig = px.scatter(
    climate,
    x="co2_ppm",
    y="temperature_anomaly",
    size="sea_level_mm",
    color="era",
    animation_frame="decade",
    animation_group="year",
    hover_name="year",
    range_x=[270, 430],
    range_y=[-0.5, 1.5],
    size_max=25,
    template="simple_white",
    title="Climate Indicators by Decade",
    labels={"co2_ppm": "CO₂ (ppm)", "temperature_anomaly": "Temp anomaly (°C)"},
)

This animates the CO2-vs-temperature scatter across decades, with bubble size encoding sea level. The play button at the bottom steps through time, and the reader can see the modern era trajectory pull away from the pre-industrial cluster. This is the Gapminder treatment applied to climate data, and it communicates the acceleration of the 20th century in a way that a static line chart cannot.

The climate project thread will continue in Chapter 21 (a dual-axis dashboard version) and Chapter 22 (an Altair declarative version), giving the reader three parallel implementations of the same analysis across three different interactive libraries.

20.13 Plotly Express vs. matplotlib vs. seaborn

Where does Plotly Express fit compared to the static libraries?

Dimension matplotlib seaborn Plotly Express
Output Static (PNG, PDF, SVG) Static Interactive (HTML), static via kaleido
Interactivity None by default None Hover, zoom, pan, click, filter out of the box
API style Imperative (pyplot) or OO Declarative, DataFrame-first Declarative, DataFrame-first
Typographic control Excellent Excellent Good but limited vs. print-quality output
Statistical overlays Manual Rich (regression, KDE, CI bands) Some (trendline, histograms, box plots)
File size Small (PNG, PDF) Small Large (HTML with embedded JS)
Offline use Always Always Requires kaleido for static export
Learning curve Steep but foundational Gentle if you know matplotlib Gentle — similar to seaborn
Best for Publication-quality print Exploratory statistical viz Interactive dashboards and web-embedded charts

None of these libraries obsoletes the others. Matplotlib is still the right choice when the output will be printed or included in a LaTeX document. Seaborn is still the right choice for exploratory statistical analysis with rich distributional tools. Plotly Express is the right choice for interactive sharing, dashboards, and any output that will be viewed in a browser. Professional practitioners learn all three and switch based on the delivery format.

The common mistake is to pick one tool and use it for everything. Matplotlib users sometimes spend hours trying to build interactive features that Plotly provides for free. Plotly users sometimes fight the library for print-quality typographic control that matplotlib does effortlessly. The practitioner who knows which tool to reach for in each situation is more productive than the practitioner who has mastered only one.

20.14 Working with Plotly in Notebooks and Scripts

Where Plotly figures are displayed depends on the environment, and understanding the options will save you confusion when charts do not appear where you expect.

Jupyter notebooks. Plotly works natively in Jupyter. Returning a fig object from a cell displays it inline, the same way DataFrames and matplotlib figures display. You can also call fig.show() explicitly. In classic Jupyter (the old notebook interface), you might need to install the ipywidgets package and sometimes run jupyter labextension install jupyterlab-plotly for JupyterLab. In recent versions, Plotly renders without any setup.

Python scripts. If you run a Python script that calls fig.show(), Plotly opens a new browser tab and displays the chart there. This works because Plotly starts a temporary local web server, serves the chart HTML to the browser, and closes the server when the process ends. For scripts that produce many charts, this opens many tabs, which is annoying. The solution is usually to write to HTML directly with pio.write_html instead of calling fig.show.

IDEs. VS Code, PyCharm, and Spyder all support Plotly display to varying degrees. VS Code has a built-in "Plotly" viewer that renders in a panel. PyCharm's scientific mode displays Plotly in its plot pane. Spyder uses the system browser. Configure your IDE's Plotly renderer via pio.renderers.default — common values are "browser", "notebook", "notebook_connected", "vscode", "pycharm", "png" (for a static preview), and "sphinx_gallery" (for documentation builds).

The renderer concept. Plotly's rendering is pluggable. Calling pio.renderers.default = "browser" configures all subsequent fig.show() calls to open a browser tab. Calling pio.renderers.default = "notebook" configures them to render inline. You can set this in a project-level configuration file or at the top of each script. Once set, it applies until you change it again.

The include_mathjax and config options. When writing to HTML, you can control additional features. include_mathjax="cdn" enables LaTeX math rendering in titles and labels. The config argument to pio.write_html controls the toolbar and other interactive features — for example, config={"displaylogo": False} hides the Plotly logo, and config={"modeBarButtonsToRemove": ["lasso2d", "select2d"]} removes specific toolbar buttons. These are details you will care about when you ship Plotly charts to stakeholders, not when you are exploring.

20.15 A Note on the Interactive Mindset

The shift to interactive visualization is not just a technical change. It is a mindset change.

When you make a static chart, you make every design decision in advance. The reader sees exactly what you chose: this axis range, this color, this annotation. Every reader sees the same chart. Your job is to anticipate the questions the reader will have and answer them on the page. If you do not answer a question, the reader cannot ask it.

When you make an interactive chart, you can give the reader the ability to ask follow-up questions. The reader can zoom to a specific period. The reader can hover to see exact values. The reader can click a legend entry to hide a category and compare the remaining ones. You do not need to anticipate every question because the reader can investigate. This is a different kind of design work. Instead of designing the answer, you are designing the question space — the set of questions the reader can ask and the affordances that let them ask.

This is why the threshold concept of this chapter is "interactive is not a gimmick." Interactivity fundamentally changes what a chart is for. It is no longer a frozen answer; it is a living tool for exploration. The best interactive charts respect this difference: they put the overview on the screen, give the reader affordances to zoom and filter, and reveal details on hover. The worst interactive charts are static images with gratuitous hover tooltips that add noise without adding understanding.

Designing a good interactive chart takes the same craft as designing a good static chart — everything from Parts I and II still applies — plus a new layer of thought about what the reader should be able to do. Hover should answer a question the reader is likely to have. Zoom should be useful for the range of data you have. Animation should reveal change over time, not just add motion. Every interactive affordance should have a purpose; otherwise, it is chart-junk by another name.

20.16 The Cost of Interactivity

Interactive charts are not free. They come with costs that the practitioner should weigh against the benefits.

File size. A Plotly HTML file with embedded plotly.js is several megabytes. With CDN mode the file is smaller (tens of kilobytes) but requires internet access. A matplotlib PNG of the same chart is typically under 100 KB. If you are emailing a chart to a stakeholder on a slow connection, or embedding dozens of charts in a report, the file size difference matters.

Rendering performance. A Plotly chart with 100 points renders instantly. A Plotly chart with 10,000 points renders smoothly on a modern laptop. A Plotly chart with 100,000 points feels sluggish during pan and zoom, and a Plotly chart with 1 million points is unusable — the browser cannot redraw fast enough to feel responsive. For large datasets, you must pre-aggregate (bin, sample, or summarize) before plotting, or switch to WebGL-accelerated trace types (scattergl instead of scatter, contourgl instead of contour). Chapter 28 goes deeper into big-data visualization strategies.

Print compatibility. A Plotly chart is built for screens. When you export it to a static image for print, you lose the interactivity (which was the main reason to use Plotly), and you are left with an image that is often less refined than what matplotlib would have produced with the same effort. If your final delivery is print, you should probably use matplotlib from the start.

Accessibility. Interactive affordances like hover are inaccessible to keyboard-only users and screen readers. A user without a mouse cannot hover over a point. A user with a screen reader gets minimal information from a Plotly chart because the chart's content is rendered in SVG and Canvas rather than in semantic HTML. Plotly has been improving its accessibility story, but interactive charts remain harder to make accessible than static charts with alt text. For public-facing content, consider whether you need a static fallback, a data table, or an explicit text description alongside the chart.

Version stability. Plotly is under active development, and the JSON spec format has changed across major versions. A chart saved as JSON with Plotly 5.0 may need tweaks to render correctly in Plotly 6.0. Static PNG images, by contrast, are forward-compatible forever. If you need a chart to look the same in ten years, a PNG is a safer bet than a Plotly HTML file.

These costs do not argue against using Plotly — they argue for matching the tool to the delivery context. Use Plotly when the reader will interact with the chart. Use matplotlib when the reader will print it, archive it, or view it in a constrained environment where interactivity does not apply.

20.17 Trendlines and Statistical Overlays

Plotly Express supports a limited but useful set of statistical overlays through the trendline parameter of px.scatter. The options include:

  • trendline="ols" — ordinary least squares linear regression
  • trendline="lowess" — locally weighted scatterplot smoothing
  • trendline="rolling" — rolling average (specify trendline_options={"window": N})
  • trendline="expanding" — expanding mean
  • trendline="ewm" — exponentially weighted moving average
fig = px.scatter(
    climate,
    x="co2_ppm",
    y="temperature_anomaly",
    trendline="ols",
    trendline_color_override="red",
    hover_data={"year": True},
)

The OLS trendline uses statsmodels under the hood, and Plotly extracts the fitted parameters so you can inspect them:

results = px.get_trendline_results(fig)
print(results.iloc[0]["px_fit_results"].summary())

This returns a statsmodels summary with the slope, intercept, R-squared, and p-values — useful when you want the regression statistics without building a separate analysis.

The trendline_scope parameter controls whether the trendline is fit per-color-group or across all points. With trendline_scope="overall", one line is fit across everything; with trendline_scope="trace" (the default when color is set), a separate line is fit per group. This parallels seaborn's lmplot behavior.

Beyond trendlines, Plotly Express supports the statistical chart types already mentioned (px.histogram, px.box, px.violin, px.ecdf) and the pair-plot equivalent px.scatter_matrix. For richer statistical overlays — confidence bands, bootstrap intervals, kernel density estimates with bandwidth control — Plotly is less capable than seaborn, and you will often drop into Graph Objects (Chapter 21) to build custom overlays. The trade-off is clear: interactivity is easy, deep statistical polish is harder.

A practical recommendation: use Plotly for the interactive exploration phase, and switch to seaborn or matplotlib when you need a specific statistical overlay that Plotly does not provide. Nothing prevents you from using both libraries in the same project — they target different phases of the workflow, not different kinds of data. An analyst who begins exploration in a Plotly notebook and finishes with a seaborn publication figure is using each tool for what it does best, and this is not a sign of inconsistency but of practical fluency with the ecosystem.

20.18 Check Your Understanding

Before continuing to Chapter 21 (Plotly Graph Objects), make sure you can answer:

  1. What are the three main components of a Plotly Figure, and what does each contain?
  2. What is the difference between Plotly Express and Plotly Graph Objects?
  3. Which parameter of px.scatter maps a categorical variable to color?
  4. How do you add a range slider to a time-series chart?
  5. When should you use animation_frame and when should you use facet_col instead?
  6. How does hover_data differ from a custom hovertemplate?
  7. What is kaleido, and what is it used for?
  8. Name two strengths and two weaknesses of Plotly Express compared to matplotlib.

If any of these are unclear, re-read the relevant section. Chapter 21 goes deeper into Plotly's architecture and exposes the full Graph Objects API for custom layouts and complex interactive controls.

20.19 Chapter Summary

This chapter introduced Plotly Express as the entry point to interactive visualization in Python:

  • Plotly is a Python wrapper around the JavaScript library plotly.js, and every Plotly figure is a JSON spec that gets rendered in a browser.
  • A Figure contains a list of traces (data layers) and a layout (everything else).
  • Plotly Express is a high-level API that produces most standard chart types with a single function call, similar in philosophy to seaborn.
  • Core chart types (px.scatter, px.line, px.bar, px.histogram, px.box, px.violin) all share a consistent parameter set — x, y, color, size, symbol, hover_name, hover_data, facet_col, facet_row, animation_frame.
  • Interactivity is built in. Every Plotly chart has hover tooltips, zoom, pan, and a toolbar without any extra code.
  • Hover customization has three levels: hover_name/hover_data, custom hovertemplate strings, and disabling hover.
  • Animation uses animation_frame and animation_group, and should always have fixed axis ranges to avoid disorientation.
  • Styling uses templates (plotly_white, simple_white, etc.) and update_layout for finer control.
  • Export uses pio.write_html for interactive HTML and pio.write_image (via kaleido) for static PNG/SVG/PDF.

The chapter's threshold concept — interactive is not a gimmick — argues that interactivity implements Shneiderman's mantra (overview, zoom, filter, detail) at the individual-chart level and fundamentally changes what a chart is for.

Chapter 21 goes deeper. Plotly Express is a convenience layer; underneath it is Plotly Graph Objects, where you have full control over every property of every trace and every element of the layout. Graph Objects lets you build things Plotly Express cannot express: complex subplots with mixed trace types, dual-axis charts, dropdown menus that swap between views, slider-controlled animations, dashboard-style figures with custom controls. The transition from Plotly Express to Graph Objects parallels the transition from pyplot to the matplotlib OO API — a step up in power that comes with a step up in verbosity.

20.20 Spaced Review

Questions that reach back to earlier chapters:

  • From Chapter 9 (Storytelling): Does interactivity replace the need for narrative sequencing, or does it supplement it?
  • From Chapter 2 (Perception): The hover tooltip is a post-attentive affordance — the reader has to mouse over a specific point to see the details. How does this interact with Gestalt grouping?
  • From Chapter 4 (Honest Charts): An interactive range slider can be abused to let readers cherry-pick periods. What editorial responsibilities does the chart maker have when adding interactive filters?
  • From Chapter 12 (Customization Mastery): How does setting pio.templates.default compare to setting plt.style.use? What are the analogues?
  • From Chapter 16 (seaborn): Plotly Express borrows the DataFrame-first, parameter-based API from seaborn. Which seaborn functions have direct Plotly Express analogues, and which do not?

Plotly Express is the fastest way to go from a DataFrame to an interactive chart in Python. It inherits seaborn's design philosophy but produces web-native output. For most dashboarding and web-embedded tasks, Plotly Express is the right starting point; for complex custom interactive figures, Chapter 21 introduces Plotly Graph Objects; and for a different philosophical approach to the same problem, Chapter 22 introduces Altair and the grammar of graphics.