plotly
Interactive charting for Python, built on the plotly.js JavaScript library. Every figure renders as a live browser object with zoom, pan, hover tooltips, and legend toggling for free, across 30+ chart types including 3D, maps, statistical, and financial charts. There are two APIs: plotly.express for one-line charts straight from dataframes, and graph_objects for full control. Figures work in Jupyter and marimo notebooks, export to standalone HTML, and are the chart layer of the Dash web-app framework, all under an MIT license maintained by Plotly the company.
The easiest way to get genuinely interactive charts out of Python dataframes, and plotly.express is a real productivity win. Accept the heavy HTML output, the kaleido dance for static export, and the two-API split; if your end product is a PNG in a paper, matplotlib remains the better tool.
Use it if
- You want interactive charts (hover, zoom, toggle) in notebooks or HTML reports without writing any JavaScript
- You live in dataframes: plotly.express turns a pandas or polars frame into a faceted, colored, tooltipped figure in one call
- You need chart types matplotlib makes painful: 3D surfaces, choropleth and tile maps, sunbursts, candlesticks, animated frames
- You plan to build a Dash app later; the figures carry over unchanged
- You need static figures for papers or print: matplotlib is the standard there, and plotly's static export drags in the separate kaleido package, which drives a headless Chromium
- Your output size matters: a standalone HTML file embeds several megabytes of plotly.js per figure unless you set include_plotlyjs='cdn' and accept an internet dependency
- You plot millions of points: default SVG rendering crawls at that scale, and even the WebGL modes have limits interactive-first design cannot dodge
- You want one obvious API: express and graph_objects overlap, most real figures end up mixing both plus update_layout, and Stack Overflow answers switch between them without warning
- Fully offline, locked-down environments: widget rendering in notebooks needs anywidget, static export needs kaleido's browser download, and both are post-install surprises
Setup reality
pip install plotly works and fig.show() in a browser or classic script is instant. The friction is everything around it: Jupyter widget rendering needs the separate anywidget package, static image export (write_image) needs kaleido, which pulls a headless Chromium and fails in odd ways on slim Docker images and CI, and county-level geo features need a separate plotly-geo package. Since v6, dataframe interop goes through narwhals, so pandas is no longer a hard dependency but most examples still assume it. Expect to spend time in update_layout margins, template, and hovertemplate syntax to make default charts presentation-ready.
Patterns
One-line chart with plotly.expressquick-chart
import plotly.express as px
fig = px.bar(x=["a", "b", "c"], y=[1, 3, 2])
fig.show()px is the high-level API; start here and drop to graph_objects only when you need trace-level control.
Scatter from a dataframe with color and sizedataframe-scatter
import plotly.express as px
df = px.data.gapminder().query("year == 2007")
fig = px.scatter(
df, x="gdpPercap", y="lifeExp",
color="continent", size="pop",
hover_name="country", log_x=True,
)
fig.show()Column names map straight to visual channels; since v6 polars and other frames work too via narwhals.
Multi-series line chartline-chart
fig = px.line(df, x="date", y="value", color="series",
markers=True)
fig.show()px.line needs long-format (tidy) data; wide dataframes should go through df.melt() first.
Titles, size, and templatestyle-layout
fig.update_layout(
title="Revenue by month",
template="plotly_white",
width=800, height=450,
margin=dict(l=40, r=20, t=60, b=40),
legend=dict(orientation="h", y=-0.2),
)Nearly all styling questions end at update_layout; template='plotly_white' is the usual first fix for the default grey look.
Control the hover tooltiphover-tooltip
fig = px.scatter(df, x="gdpPercap", y="lifeExp",
hover_data={"pop": ":,", "gdpPercap": ":.0f"})
# full control on a trace:
fig.update_traces(
hovertemplate="%{customdata[0]}<br>GDP: %{x:$,.0f}<extra></extra>"
)The <extra></extra> tag removes the secondary trace-name box; hovertemplate uses d3-format strings, not Python format specs.
Multiple charts in one figuresubplots
from plotly.subplots import make_subplots
import plotly.graph_objects as go
fig = make_subplots(rows=1, cols=2, subplot_titles=("A", "B"))
fig.add_trace(go.Scatter(x=x, y=y1), row=1, col=1)
fig.add_trace(go.Bar(x=x, y=y2), row=1, col=2)
fig.show()make_subplots only accepts graph_objects traces; you cannot drop a px figure into a cell, though you can copy its fig.data traces over.
Build a figure trace by tracegraph-objects
import plotly.graph_objects as go
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=actual, name="actual", mode="lines"))
fig.add_trace(go.Scatter(x=x, y=forecast, name="forecast",
mode="lines", line=dict(dash="dot")))
fig.show()graph_objects is verbose but explicit; use it when express keyword arguments run out.
Save an interactive HTML fileexport-html
fig.write_html("report.html", include_plotlyjs="cdn")The default embeds all of plotly.js (several MB) in every file; include_plotlyjs='cdn' keeps files small but requires internet to view.
Export a static PNG or PDFexport-png
# pip install -U kaleido
fig.write_image("chart.png", scale=2)
fig.write_image("chart.pdf")Requires the separate kaleido package, which runs a headless Chromium; on slim Docker images install its browser deps or this fails at runtime.
Plot large datasets with WebGLlarge-data-webgl
fig = px.scatter(df, x="x", y="y", render_mode="webgl")
# or explicitly:
fig = go.Figure(go.Scattergl(x=x, y=y, mode="markers"))Default SVG rendering degrades past a few thousand points; WebGL handles far more but loses some styling options like dashed lines.
Small multiples with facetsfacet-grid
fig = px.scatter(df, x="gdpPercap", y="lifeExp",
facet_col="continent", facet_col_wrap=3)
fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))Facet titles render as 'column=value'; the for_each_annotation line is the standard trick to strip the prefix.
Animate over a variableanimation
fig = px.scatter(px.data.gapminder(),
x="gdpPercap", y="lifeExp", size="pop",
color="continent", animation_frame="year",
log_x=True, range_y=[20, 90])
fig.show()Set explicit range_x/range_y or the axes rescale every frame; animations do not survive static image export.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| matplotlib | PyPI | Publication-quality static figures, print output, or the massive ecosystem built on it (seaborn, pandas .plot) |
| altair | PyPI | You prefer a declarative Vega-Lite grammar and mostly make standard statistical charts |
| bokeh | PyPI | Interactive plots with Python server callbacks and streaming data, without adopting Dash |