> "Plotly Express is the 80% solution. Graph Objects is for the 20% that matter most."
Learning Objectives
- Create figures using go.Figure(data=..., layout=...) with direct property control
- Add and configure individual traces: go.Scatter, go.Bar, go.Heatmap, go.Box, go.Violin
- Build subplots using make_subplots() with shared axes, mixed trace types, and row/column spans
- Implement interactive controls: buttons and dropdowns via updatemenus
- Configure dual y-axes and apply custom tick formatting and annotations
- Apply advanced styling: custom colorscales, shape annotations, image overlays
- Explain the Figure data structure as nested dictionaries and debug by inspecting fig.to_dict()
In This Chapter
- 21.1 When Plotly Express Is Not Enough
- 21.2 The Figure Anatomy
- 21.3 Essential Trace Types
- 21.4 Building Subplots with make_subplots
- 21.5 Dual Y-Axes (and the Ethical Warning)
- 21.6 Interactive Buttons with updatemenus
- 21.7 Sliders and Animation Control
- 21.8 Annotations, Shapes, and Reference Lines
- 21.9 Custom Colorscales and Styling
- 21.10 Inspecting and Debugging Figures
- 21.11 Progressive Project: Interactive Climate Dashboard
- 21.12 The update_traces and update_layout Pattern
- 21.13 Frames and Custom Animation
- 21.14 Subplot Titles and Axis Formatting
- 21.15 Performance: Scattergl and Aggregation
- 21.16 Working with Legends and Hover Modes
- 21.17 Templates and Reusable Styling
- 21.18 When to Choose Graph Objects Over Plotly Express
- 21.19 Debugging Common Issues
- 21.20 Graph Objects in a Larger Workflow
- 21.21 Check Your Understanding
- 21.22 Chapter Summary
- 21.23 Spaced Review
Chapter 21: Plotly Graph Objects — Full Customization and Complex Layouts
"Plotly Express is the 80% solution. Graph Objects is for the 20% that matter most." — Plotly documentation
21.1 When Plotly Express Is Not Enough
Plotly Express, introduced in Chapter 20, is deliberately constrained. Each function produces one chart type with one set of parameters. You can produce beautiful interactive charts with a single line of code — and for most exploratory analysis and standard reporting, that is enough. But the constraints start to bite when you want to do any of the following:
- Mix chart types in the same figure. A scatter of raw points with a line of the fitted trend and a band of the confidence interval. A bar chart of revenue with an overlay line of the cumulative sum. A heatmap with annotations drawn on top.
- Build complex subplot layouts. Three charts in a row where the first two share a y-axis but the third does not. A dashboard-style layout with a big hero chart and four small supporting charts. A pair plot where some panels are scatter plots and others are density contours.
- Add interactive controls beyond hover and zoom. A dropdown that swaps between "Cases" and "Deaths" on a COVID chart. Buttons that toggle individual traces. Sliders that control animation frames at finer granularity than Plotly Express provides.
- Configure dual y-axes. Temperature and CO2 on the same chart with different scales. Revenue and units sold on the same chart.
- Add custom annotations and shapes. A highlighted region showing a recession period. A horizontal reference line at y=0. An image watermark in the corner.
- Debug the JSON spec. Sometimes a figure does not render the way you expect, and the fastest way to diagnose the problem is to inspect the underlying JSON directly.
All of these tasks are possible with Plotly Express — by calling fig.update_traces, fig.update_layout, and fig.add_trace after constructing the initial figure. But the transition to Plotly Graph Objects is often cleaner. Graph Objects (plotly.graph_objects, conventionally imported as go) is the underlying API that Plotly Express wraps. When you reach for Graph Objects directly, you get full access to every property of every trace and every element of the layout — without the convenience-layer abstractions getting in your way.
The chapter's threshold concept is that a Plotly figure is a data structure. Not a canvas. Not an Artist tree. A nested Python dictionary (or an equivalent object hierarchy) that describes what the chart should look like. When you call go.Figure(data=..., layout=...), you are constructing a data structure; when you display it, Plotly serializes the data structure to JSON and hands it to plotly.js for rendering. There is no "drawing" in the Python layer. You describe what you want, and the JavaScript renderer produces it. This is a different mental model from matplotlib's imperative drawing calls, and grasping the difference is the key to using Graph Objects effectively.
21.2 The Figure Anatomy
Every Plotly figure has exactly three top-level components:
data: a list of trace objects. Each trace is a visual layer with its own type (scatter,bar,heatmap, etc.) and its own data arrays and style properties.layout: an object (a dictionary under the hood) containing the title, axes, legend, background, margins, annotations, shapes, updatemenus, and every other non-trace property.frames: an optional list of frame objects for animation. Each frame contains data snapshots that the figure transitions through when animated.
Constructing a Figure from 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", name="y = x²"),
go.Scatter(x=[1, 2, 3], y=[1, 8, 27], mode="lines+markers", name="y = x³"),
],
layout=go.Layout(
title="Polynomials",
xaxis=dict(title="x"),
yaxis=dict(title="y"),
),
)
fig.show()
The data list contains two Scatter traces — one per line. The layout contains the title and axis labels. This is more verbose than the Plotly Express equivalent:
import plotly.express as px
import pandas as pd
df = pd.DataFrame({"x": [1, 2, 3], "y1": [1, 4, 9], "y2": [1, 8, 27]})
fig = px.line(df, x="x", y=["y1", "y2"], title="Polynomials")
...but the verbosity is the point. With Graph Objects, you can add a third trace of a different type (e.g., a bar chart), customize individual trace properties that Plotly Express does not expose, and build arbitrary layouts. The flexibility is worth the extra lines.
Every Plotly Express figure can be converted to its Graph Objects representation. In fact, it is a Graph Objects figure — Plotly Express builds go.Figure objects internally and returns them. You can take a Plotly Express figure and modify it with Graph Objects methods:
fig = px.scatter(df, x="x", y="y") # Plotly Express
fig.add_trace(go.Scatter(x=[5, 6, 7], y=[2, 4, 6], name="Extra points")) # Graph Objects
fig.update_layout(title="Combined") # Graph Objects
This hybrid workflow is common in practice. Start with Plotly Express for the bulk of the figure, then drop into Graph Objects for the specific customizations that Plotly Express does not support.
21.3 Essential Trace Types
Graph Objects provides a trace type for every chart type you might need. The most common ones are:
go.Scatter — lines, markers, or both. The workhorse for line charts, scatter plots, and area charts. Key parameters: x, y, mode ("lines", "markers", "lines+markers", "text", or combinations), name, marker, line, fill, hovertemplate.
go.Bar — bar charts. Key parameters: x, y, name, orientation ("v" or "h"), marker.color, base (for offset bars). Combine multiple Bar traces in one figure with layout.barmode = "group" or "stack".
go.Histogram — histograms. Key parameters: x (or y for horizontal), nbinsx, xbins (explicit bin specification), histnorm ("count", "percent", "probability", "density", "probability density"), cumulative.
go.Box and go.Violin — box plots and violin plots. Key parameters: x, y, name, points ("all", "outliers", "suspectedoutliers", False), boxmean, notched (for Box), box (for Violin, to overlay a box inside).
go.Heatmap — 2D heatmaps. Key parameters: z (the matrix), x and y (axis labels), colorscale, showscale, zmin/zmax/zmid, text (annotations), texttemplate (for cell labels).
go.Contour — contour plots on a 2D grid. Same parameters as Heatmap plus contours for contour level configuration.
go.Pie and go.Sunburst — pie and sunburst charts. Pie takes values and labels; Sunburst takes ids, parents, values for the hierarchy.
go.Scattermapbox and go.Choroplethmapbox — geographic visualizations with Mapbox tiles. Chapter 23 goes deeper on geospatial.
go.Scattergl — WebGL-accelerated scatter, for datasets beyond about 50k points. Same API as Scatter but renders on the GPU.
go.Parcoords — parallel coordinates. Same functionality as px.parallel_coordinates but with more control.
go.Sankey — Sankey diagrams for flow visualization. Key parameters: node (dict with labels), link (dict with source/target/value).
Each trace type has dozens of parameters that control appearance and behavior. The full reference is at plotly.com/python/reference/, and it is the primary source for Graph Objects usage. When you need to customize a specific property, look it up there rather than guessing — the property names are consistent but numerous, and memorizing them is not practical.
21.4 Building Subplots with make_subplots
Plotly Express handles faceting automatically, but it cannot build arbitrary subplot layouts. For anything more complex than a simple grid — mixed trace types, unequal panel sizes, shared axes, secondary axes — you use plotly.subplots.make_subplots.
from plotly.subplots import make_subplots
import plotly.graph_objects as go
fig = make_subplots(
rows=2,
cols=2,
subplot_titles=("Temperature", "CO₂", "Sea Level", "Ice Extent"),
shared_xaxes=True,
vertical_spacing=0.1,
)
fig.add_trace(go.Scatter(x=years, y=temperatures, name="Temp"), row=1, col=1)
fig.add_trace(go.Scatter(x=years, y=co2, name="CO₂"), row=1, col=2)
fig.add_trace(go.Scatter(x=years, y=sea_level, name="Sea Level"), row=2, col=1)
fig.add_trace(go.Scatter(x=years, y=ice_extent, name="Ice Extent"), row=2, col=2)
fig.update_layout(height=600, title="Climate Indicators")
fig.show()
The make_subplots call returns a Figure with an empty grid. Then add_trace places each trace into a specific cell by row and col. The result is a 2×2 grid with four separate charts sharing their x-axes.
For unequal panel sizes, pass row_heights and column_widths arrays:
fig = make_subplots(
rows=2,
cols=2,
row_heights=[0.7, 0.3], # top row is 70% of height, bottom is 30%
column_widths=[0.6, 0.4], # left column is 60% of width, right is 40%
shared_xaxes=True,
)
For spanning cells (a single panel that occupies multiple rows or columns), use the specs parameter:
fig = make_subplots(
rows=2,
cols=2,
specs=[
[{"colspan": 2}, None], # row 1 has one panel spanning both columns
[{}, {}], # row 2 has two separate panels
],
)
The specs list is a 2D list mirroring the grid. Each cell is a dict describing the panel, or None if the cell is "occupied" by a spanning panel from an earlier cell. The "colspan": 2 entry tells Plotly that the cell occupies the next cell as well. This is the same pattern as matplotlib's GridSpec, just expressed differently.
For mixed trace types, the specs entries can specify the subplot type:
fig = make_subplots(
rows=1,
cols=2,
specs=[[{"type": "xy"}, {"type": "polar"}]],
)
fig.add_trace(go.Scatter(x=x, y=y), row=1, col=1)
fig.add_trace(go.Scatterpolar(r=r, theta=theta), row=1, col=2)
The left panel is a Cartesian x-y chart; the right panel is a polar chart. Mixed types require the specs declaration because each subplot type has a different axis system.
21.5 Dual Y-Axes (and the Ethical Warning)
Dual-y-axis charts put two different variables on the same plot with two different y-scales. They are useful when you want to show the correlation between two variables that have very different units — temperature and CO2, revenue and units sold, stock price and trading volume. They are also infamous for distortion: by manipulating the two scales, you can make any two series look visually correlated or anti-correlated regardless of what the data actually shows.
To build a dual-y-axis chart with Graph Objects:
from plotly.subplots import make_subplots
fig = make_subplots(specs=[[{"secondary_y": True}]])
fig.add_trace(
go.Scatter(x=years, y=temperatures, name="Temperature (°C)"),
secondary_y=False,
)
fig.add_trace(
go.Scatter(x=years, y=co2, name="CO₂ (ppm)"),
secondary_y=True,
)
fig.update_yaxes(title_text="Temperature anomaly (°C)", secondary_y=False)
fig.update_yaxes(title_text="CO₂ concentration (ppm)", secondary_y=True)
fig.update_layout(title="Temperature and CO₂ Over Time")
The make_subplots(specs=[[{"secondary_y": True}]]) call enables the secondary y-axis on the single subplot. Each add_trace call then specifies which y-axis it should use. The result is a chart with two y-axes, one on the left and one on the right, each labeled with its own units.
The ethical warning from Chapter 4 applies with full force here. A dual-y-axis chart can make anything look correlated by choosing the scales carefully. If you set the temperature axis to range from -0.5 to 1.5 and the CO2 axis to range from 280 to 420, the two series will appear to track each other nicely — but the apparent tracking is a product of your axis choices, not the underlying relationship. A reader who does not notice the dual axes will draw conclusions that the data does not support.
Guidelines for using dual-y-axis charts ethically:
- Only when the two variables are genuinely related. If there is no causal or mechanistic relationship, the dual-axis chart suggests a relationship that does not exist. Use small multiples or separate charts instead.
- Disclose the scale choices. Make sure the axis ranges are obvious. Don't hide them or make them hard to read.
- Consider alternatives. A scatter plot of one variable against the other (x vs. y, not both vs. time) is usually more honest for showing correlation. Two line charts stacked above each other with a shared x-axis also works and is less prone to misinterpretation.
- Consider the audience. Technical readers can parse dual-axis charts carefully. General-audience readers often cannot, and the chart can mislead them. If the audience includes non-technical readers, lean toward alternatives.
The point is not that dual-y-axis charts are wrong. They have legitimate uses, especially in technical contexts where the relationship between two variables with different units is the main question. The point is that they require care. Use them deliberately, not as a default.
21.6 Interactive Buttons with updatemenus
One of the features Plotly Express cannot produce is a set of buttons that modifies the figure when clicked. For that you need the updatemenus system, which is part of the layout object.
A typical use: a toggle between two data series. Suppose you have a dataset with daily COVID cases and daily deaths, and you want a chart that lets the reader switch between them.
fig = go.Figure()
fig.add_trace(go.Scatter(x=dates, y=cases, name="Cases", visible=True))
fig.add_trace(go.Scatter(x=dates, y=deaths, name="Deaths", visible=False))
fig.update_layout(
updatemenus=[
dict(
type="buttons",
direction="right",
x=0.5,
y=1.15,
xanchor="center",
buttons=[
dict(
label="Cases",
method="update",
args=[{"visible": [True, False]}, {"title": "Daily Cases"}],
),
dict(
label="Deaths",
method="update",
args=[{"visible": [False, True]}, {"title": "Daily Deaths"}],
),
],
)
]
)
Each button has a label, a method ("update", "restyle", "relayout", or "animate"), and args. The args list contains the trace property updates (first element) and the layout property updates (second element). For a toggle, the trace update is the visible array telling which trace should be shown.
The type="buttons" creates a horizontal row of buttons. Changing to type="dropdown" produces a dropdown menu instead:
fig.update_layout(
updatemenus=[
dict(
type="dropdown",
buttons=[
dict(label="Cases", method="update", args=[{"visible": [True, False]}]),
dict(label="Deaths", method="update", args=[{"visible": [False, True]}]),
],
)
]
)
Dropdowns are useful when there are many options (more than about five) and buttons become visually crowded.
The updatemenus system is powerful but verbose. Complex interactions often require longer button specifications with multiple updates per click. The pattern is always the same: list the buttons, each button has a label and a method and args, the args describe what to change in the figure. Once you internalize the pattern, building custom interactive controls becomes a matter of writing the right args list.
21.7 Sliders and Animation Control
A slider is a visual control at the bottom of a figure that lets the reader drag between frames or parameter values. Plotly Express adds a slider automatically when you set animation_frame, but the slider is generic. For custom sliders with specific step labels, specific frame transitions, or specific layout, you use the sliders layout property.
fig.update_layout(
sliders=[
dict(
active=0,
currentvalue={"prefix": "Year: "},
pad={"t": 50},
steps=[
dict(
label=str(year),
method="animate",
args=[[str(year)], {"frame": {"duration": 300}, "mode": "immediate"}],
)
for year in years
],
)
]
)
Each step has a label, a method ("animate" for animations), and args that specify which frame to jump to and how. The frames themselves are defined separately with fig.frames = [go.Frame(...), ...].
This is another verbose API, and in most cases the Plotly Express animation_frame parameter is enough. You reach for the lower-level API when you need fine control — custom labels per step, non-uniform frame durations, combined play buttons and sliders with specific behaviors.
21.8 Annotations, Shapes, and Reference Lines
Static annotations (text, shapes, and reference lines) are another place where Graph Objects shines. Plotly Express adds basic annotations through function parameters, but for arbitrary text and shapes you use the layout-level annotations and shapes lists or the convenience methods add_annotation, add_shape, add_hline, add_vline, and add_hrect/add_vrect.
fig.add_annotation(
x=2020,
y=1.2,
text="COVID-19 pandemic begins",
showarrow=True,
arrowhead=2,
ax=-40,
ay=-40,
bordercolor="red",
borderwidth=1,
)
fig.add_hline(y=0, line_dash="dash", line_color="gray")
fig.add_vrect(x0=2008, x1=2009, fillcolor="lightgray", opacity=0.3, annotation_text="Recession")
Each of these calls adds to the figure's layout without disturbing the traces. The add_hline and add_vline are shortcuts for horizontal and vertical reference lines. The add_hrect and add_vrect are shortcuts for horizontal and vertical bands — useful for marking periods (recessions, test phases, treatment windows) on time-series charts.
Annotations in Plotly support the same action-title approach discussed in Chapter 7. A chart title can be a simple topic ("Daily COVID Cases") or an action title ("Cases fell by 80% after vaccination rollout"). The action title is almost always more effective, and you set it with fig.update_layout(title="...") the same way in Graph Objects as in Plotly Express.
21.9 Custom Colorscales and Styling
Plotly ships with many built-in colorscales — Viridis, Plasma, Inferno, RdBu, Blues, Greens, Portland, Electric, and more. You can reference them by name in any colorscale-accepting parameter. But sometimes you need a custom colorscale, either for brand consistency or for a specific perceptual need.
A colorscale is a list of [value, color] pairs, where values are between 0 and 1 and colors are hex codes or named colors:
custom_scale = [
[0.0, "#2c7bb6"],
[0.25, "#abd9e9"],
[0.5, "#ffffbf"],
[0.75, "#fdae61"],
[1.0, "#d7191c"],
]
fig.add_trace(go.Heatmap(z=matrix, colorscale=custom_scale))
The values must be monotonically increasing from 0 to 1. Plotly linearly interpolates between the specified points, so a 5-point scale produces smooth gradients between each pair.
For diverging scales (with a meaningful midpoint), set zmid=0 on the trace to anchor the midpoint of the colorscale to zero. This is the same center concept from Chapter 19's heatmap discussion.
Beyond colorscales, Graph Objects exposes fine control over fonts, margins, backgrounds, gridlines, and every other styling property through fig.update_layout. Templates still apply: fig.update_layout(template="simple_white") sets the base styling, and subsequent update_layout calls override specific properties.
21.10 Inspecting and Debugging Figures
One of the most useful features of Graph Objects — and one that new users often miss — is the ability to inspect a figure as a Python dict and debug it directly.
print(fig.to_dict())
This prints the full figure as a nested dictionary. Every property, every trace, every layout detail. For a complex figure, the output is long, but it shows you exactly what Plotly will serialize to JSON and render in the browser. If something is not rendering the way you expect, inspect the dict and look for the property you are trying to set.
A common pattern is to use to_dict() to convert a Plotly Express figure into a dict, modify it, and rebuild a figure:
fig = px.scatter(df, x="x", y="y")
spec = fig.to_dict()
# ... modify spec ...
fig2 = go.Figure(spec)
For interactive debugging, fig.show(renderer='json') displays the raw JSON in the notebook output cell. This is handy when you are trying to understand why a particular feature is not appearing — you can see the exact spec and compare it to working examples.
Another useful pattern: fig.update_traces(...) with a selector argument modifies all traces matching a condition. This is much faster than looping over fig.data by hand:
fig.update_traces(marker=dict(size=10), selector=dict(mode="markers"))
This sets the marker size to 10 for all traces whose mode is "markers", leaving line-only traces untouched. The selector mechanism is a small superpower for modifying figures with many traces.
21.11 Progressive Project: Interactive Climate Dashboard
We extend the climate chart from Chapter 20 into a more complex dashboard-style figure that uses Graph Objects features Plotly Express cannot produce.
The goal: a single figure showing temperature and CO2 on the same chart (dual y-axis), with a dropdown to switch the primary view between "temperature," "CO2," and "sea level," custom annotations for notable climate events, and a shared range slider.
from plotly.subplots import make_subplots
import plotly.graph_objects as go
fig = make_subplots(specs=[[{"secondary_y": True}]])
fig.add_trace(
go.Scatter(x=climate["year"], y=climate["temperature_anomaly"],
name="Temperature anomaly (°C)", line=dict(color="red")),
secondary_y=False,
)
fig.add_trace(
go.Scatter(x=climate["year"], y=climate["co2_ppm"],
name="CO₂ (ppm)", line=dict(color="blue")),
secondary_y=True,
)
fig.update_yaxes(title_text="Temperature anomaly (°C)", secondary_y=False)
fig.update_yaxes(title_text="CO₂ (ppm)", secondary_y=True)
fig.update_xaxes(title_text="Year")
# Annotations for key events
fig.add_vline(x=1958, line_dash="dash", line_color="gray",
annotation_text="Mauna Loa CO₂ monitoring begins")
fig.add_vline(x=1988, line_dash="dash", line_color="gray",
annotation_text="Hansen testifies to Congress")
fig.add_vline(x=2015, line_dash="dash", line_color="gray",
annotation_text="Paris Agreement")
fig.update_layout(
title="Temperature Anomaly and Atmospheric CO₂, 1880-2024",
xaxis_rangeslider_visible=True,
template="simple_white",
hovermode="x unified",
height=600,
)
fig.show()
The hovermode="x unified" setting produces a single tooltip with both temperature and CO2 values when you hover over any year. This is much more informative than separate tooltips per trace. The vertical annotations mark three turning points in climate science history. The range slider at the bottom lets the reader zoom into any period. The dual y-axis lets the reader see the strong temporal correlation between temperature and CO2 at a glance.
This is a single interactive figure that communicates something specific (the connection between CO2 and temperature) while still allowing the reader to interrogate the data (via hover, zoom, and the range slider). It is the kind of figure that Plotly Express cannot produce in a single call — it requires subplots (for the dual y-axis), annotations, and custom hover behavior. Graph Objects makes all of these accessible.
21.12 The update_traces and update_layout Pattern
Graph Objects figures can be modified after construction through two key methods: fig.update_traces and fig.update_layout. Understanding these methods is essential because they are the primary way you modify figures in practice.
fig.update_layout(**kwargs) modifies the layout object. You pass keyword arguments that map to layout properties, and Plotly updates the corresponding values. The arguments support both nested dicts and Plotly's "magic underscore" notation, where xaxis_title is equivalent to xaxis=dict(title="..."). Both of the following are valid:
fig.update_layout(xaxis=dict(title="Year", tickformat=".0f"))
fig.update_layout(xaxis_title="Year", xaxis_tickformat=".0f")
The magic-underscore notation is usually more concise, but the nested dict form is necessary when you need to set many sub-properties at once or when you want to pass a dict that was constructed earlier.
fig.update_traces(**kwargs, selector=...) modifies one or more traces. Without a selector, it modifies all traces in the figure. With a selector, it modifies only the traces that match the selector criteria. The selector can be a dict (matching on trace properties), an integer (matching a specific trace index), or a callable (for more complex matching).
# Set marker size on all traces
fig.update_traces(marker_size=10)
# Set marker size only on markers-mode traces (leave lines alone)
fig.update_traces(marker_size=10, selector=dict(mode="markers"))
# Set visibility on the second trace only
fig.update_traces(visible=False, selector=1)
These methods are idempotent and can be chained — calling them multiple times on the same figure is safe and produces the expected cumulative result. In practice, most figures are built by first creating a base figure (either with Plotly Express or Graph Objects), then making a series of update_layout and update_traces calls to customize the result. This chain-of-updates pattern is the idiomatic way to write Plotly code.
A related method is fig.add_trace(trace, row=None, col=None, secondary_y=None), which adds a new trace to an existing figure. This is how you build figures incrementally: start with an empty Figure, add traces one at a time, then customize the layout. For subplot figures created with make_subplots, pass row and col (and optionally secondary_y) to place the trace in a specific cell.
fig = go.Figure()
fig.add_trace(go.Scatter(x=x1, y=y1, name="Series 1"))
fig.add_trace(go.Scatter(x=x2, y=y2, name="Series 2"))
fig.update_layout(title="Combined", template="simple_white")
The incremental-build pattern is flexible and easy to read. It is also the most common pattern in Plotly tutorials and documentation, so adopting it makes your code consistent with the wider ecosystem.
21.13 Frames and Custom Animation
Plotly Express's animation_frame parameter handles the common animation case: one frame per value of a column, with automatic transitions. For custom animations — different data per frame, variable frame durations, or non-trivial transitions — you use the lower-level frames API directly.
A frame in Plotly is a snapshot of data. The Figure has an initial state (data + layout) plus a list of frames, and the animation plays through the frames in order, updating the figure to match each frame's spec.
import plotly.graph_objects as go
years = list(range(2000, 2025))
frames = [
go.Frame(
data=[go.Scatter(x=climate_year.co2_ppm, y=climate_year.temperature_anomaly, mode="markers")],
name=str(year),
)
for year, climate_year in climate.groupby("year") if 2000 <= year <= 2024
]
fig = go.Figure(
data=[go.Scatter(x=climate.query("year == 2000").co2_ppm,
y=climate.query("year == 2000").temperature_anomaly,
mode="markers")],
layout=go.Layout(
xaxis=dict(range=[370, 430]),
yaxis=dict(range=[0.3, 1.3]),
updatemenus=[dict(
type="buttons",
buttons=[dict(label="Play", method="animate", args=[None])],
)],
),
frames=frames,
)
The frames list contains one go.Frame per animated state. Each frame has data (the trace updates for that frame) and name (the identifier used by sliders). The figure's initial state is its data list; when you press Play, Plotly cycles through the frames, replacing the current data with each frame's data in turn.
Custom frames let you animate between arbitrary states — not just rows of a DataFrame but any structural changes. For example, you can animate the same scatter changing its x/y data, or a bar chart's heights growing from zero, or a heatmap matrix rotating through snapshots. Plotly Express cannot express these cases, so when you need them, Graph Objects frames are the tool.
21.14 Subplot Titles and Axis Formatting
When using make_subplots, the subplot_titles parameter adds a title above each panel:
fig = make_subplots(
rows=2, cols=2,
subplot_titles=("Temperature", "CO₂", "Sea Level", "Ice Extent"),
)
These titles are actually implemented as annotations on the layout, which means you can modify them after the fact with fig.layout.annotations[i].text = "new title". This is a subtle but useful detail when you want to programmatically adjust titles — for example, adding the latest value to each panel title dynamically.
Axis formatting is done through update_xaxes and update_yaxes:
fig.update_xaxes(tickformat=".0f", row=1, col=1)
fig.update_yaxes(tickformat=".2%", row=1, col=1)
The tickformat argument accepts d3-format strings, which are similar to Python's format strings but not identical. Common formats: ".0f" (no decimals), ".2f" (two decimals), ".0%" (percent with no decimals), "$,.0f" (dollar sign with thousands separators and no decimals), ",.0f" (thousands separators, no decimals). The full d3-format specification is at github.com/d3/d3-format.
For date axes, use d3's time-format strings: "%Y" for four-digit year, "%b %Y" for abbreviated month + year, "%Y-%m-%d" for ISO dates. These apply when your x-axis is a datetime column.
Per-subplot axis formatting requires the row and col arguments. Without them, update_xaxes applies to all x-axes in the figure, which is occasionally useful but more often surprising.
21.15 Performance: Scattergl and Aggregation
Plotly's default go.Scatter trace renders via SVG. SVG is high-quality but slow for large point counts — each point becomes a DOM element, and browsers struggle past about 50,000 elements. For larger datasets, Plotly provides go.Scattergl, which renders via WebGL on the GPU. The API is essentially identical, but the performance is an order of magnitude better.
fig.add_trace(go.Scattergl(x=x_large, y=y_large, mode="markers"))
For scatter plots with 100,000 to 1,000,000 points, Scattergl is the only practical choice. Beyond a million points, even WebGL struggles, and you should pre-aggregate the data — bin into a density heatmap, sample a representative subset, or summarize into aggregated groups.
Plotly Express exposes this through the render_mode parameter on some functions:
px.scatter(df, x="x", y="y", render_mode="webgl")
render_mode="webgl" tells Plotly Express to use Scattergl instead of Scatter. The default is "auto", which chooses SVG for small datasets and WebGL for large ones. You can force the choice when the auto-detection makes the wrong call.
For 2D density visualization of large datasets, use go.Histogram2d or go.Histogram2dContour (aggregating bin), or pre-compute a 2D histogram with NumPy and feed it to go.Heatmap. These aggregation approaches sidestep the per-point rendering cost entirely and handle arbitrary data sizes.
Chapter 28 goes deeper into large-data visualization strategies, including Datashader for truly massive datasets. For the majority of interactive visualization tasks, Scattergl + aggregation covers the common cases adequately.
21.16 Working with Legends and Hover Modes
Every Plotly figure has a legend by default, showing each trace's name alongside a color swatch. The legend is interactive: clicking a trace name toggles its visibility; double-clicking isolates it. For figures with many traces, this built-in filtering is one of the most useful interactive affordances — readers can hide and show categories without you writing any code.
Legend placement is controlled through fig.update_layout(legend=dict(...)):
fig.update_layout(
legend=dict(
orientation="h", # horizontal
yanchor="bottom",
y=1.02, # above the plot
xanchor="right",
x=1,
bgcolor="rgba(255,255,255,0.5)",
bordercolor="black",
borderwidth=1,
)
)
The orientation="h" places the legend horizontally, which is often better for wide charts than the default vertical. The y=1.02 positions it just above the plot area. Dozens of other legend properties exist — title, font, itemclick, groupclick — and the reference documentation is the best source.
To suppress the legend entirely: fig.update_layout(showlegend=False). This is appropriate for figures with one trace or for charts where direct labeling (Chapter 7) replaces the legend's function.
Per-trace legend control is via showlegend on individual traces:
fig.add_trace(go.Scatter(x=x, y=y, name="Main", showlegend=True))
fig.add_trace(go.Scatter(x=x, y=y_ref, name="Reference", showlegend=False))
This lets you show some traces in the legend and hide others.
Hover modes control how tooltips appear when the user mouses over the chart. The options are set via fig.update_layout(hovermode=...):
"closest"(default) — show the tooltip for the single nearest point."x"— show tooltips for all traces at the hovered x value (one tooltip per trace)."x unified"— show a single unified tooltip at the hovered x value, listing all trace values."y"and"y unified"— same but for y values.False— disable hover entirely.
For time-series charts with multiple series, "x unified" is almost always the right choice. It produces a compact tooltip that shows all series' values at the hovered date, which is exactly what most readers want. The default "closest" mode works better for scatter plots where individual points matter more than coordinated values.
21.17 Templates and Reusable Styling
Chapter 12 showed how to build reusable matplotlib style sheets. Plotly has an equivalent system called templates. A template is a serialized layout + trace defaults that you can apply to any figure with a single parameter.
Plotly ships with a dozen built-in templates: "plotly", "plotly_white", "plotly_dark", "simple_white", "presentation", "ggplot2", "seaborn", "gridon", "xgridoff", "ygridoff", "none". You can set the default template globally:
import plotly.io as pio
pio.templates.default = "simple_white"
Every subsequent figure uses this template unless you override it with template="..." in a Plotly Express call or fig.update_layout(template="...").
For custom templates — your organization's brand colors, your preferred font, your default margins — you can create a template programmatically:
import plotly.graph_objects as go
custom_template = go.layout.Template(
layout=dict(
font=dict(family="Helvetica, Arial, sans-serif", size=12, color="#333"),
paper_bgcolor="white",
plot_bgcolor="white",
xaxis=dict(gridcolor="#eee", linecolor="#333", tickfont=dict(color="#333")),
yaxis=dict(gridcolor="#eee", linecolor="#333", tickfont=dict(color="#333")),
colorway=["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd"],
margin=dict(l=60, r=30, t=60, b=60),
)
)
pio.templates["company_brand"] = custom_template
pio.templates.default = "simple_white+company_brand"
The "simple_white+company_brand" syntax composes two templates, applying simple_white first and then layering company_brand on top. This lets you start from a well-designed base and add your specific overrides.
For an organization producing many Plotly charts, building a shared template module is the Plotly equivalent of the matplotlib style system discussed in Chapter 12. It eliminates the need to customize every figure individually and ensures visual consistency across reports and dashboards.
21.18 When to Choose Graph Objects Over Plotly Express
A question that comes up constantly: should I use Plotly Express or Graph Objects for this chart? The answer is almost always "both" — most real projects use Plotly Express for the bulk of the figure and drop to Graph Objects for specific customizations. But there are clear cases where one or the other is strictly better.
Use Plotly Express when:
- The chart fits a standard type (scatter, line, bar, histogram, box, violin, pie, heatmap, treemap, sunburst, pair plot).
- The data is in a tidy DataFrame.
- You want the figure in one or two lines of code.
- You do not need custom buttons, dropdowns, or dual axes.
- You are iterating quickly on exploratory analysis.
Use Graph Objects when:
- You need a chart type Plotly Express does not offer (Sankey diagrams, parallel coordinates with specific tweaks, custom polar charts).
- You need to mix trace types in a single subplot (scatter + line + band in one panel).
- You need dual y-axes.
- You need interactive buttons or dropdowns.
- You need complex subplot layouts with spanning cells, unequal sizes, or mixed subplot types.
- You need custom animation frames with non-uniform behavior.
- You are debugging a figure and need to inspect its JSON structure.
Use both (the common case):
- Start with Plotly Express for the initial chart — quickest to write, most bugs caught by sensible defaults.
- Modify with
fig.update_traces,fig.update_layout, andfig.add_tracefrom Graph Objects when you need a property that Plotly Express does not expose. - The hybrid approach is idiomatic and produces the best of both worlds.
There is nothing wrong with starting in Plotly Express and never leaving. For the 80% of charts where it works, it is the better choice. The skill is recognizing when you are fighting Plotly Express to make it do something it does not want to do, and switching to Graph Objects instead of fighting.
A concrete heuristic: if your Plotly Express call requires more than three chained fig.update_* calls afterward to produce what you want, consider rewriting from scratch with Graph Objects. The Graph Objects version will be more verbose but usually clearer — the intent is visible in the construction rather than buried under a sequence of patches.
21.19 Debugging Common Issues
Graph Objects gives you power, and with power comes more ways to break things. Here are the failure modes that come up most often in practice.
Property name typos. Plotly uses d3-style property names (camelCase or snake_case depending on context), not Python conventions. A typo like title_text vs title vs name produces a silent failure — the property is not recognized, the figure renders without your change, and you are left wondering what happened. Fix: inspect fig.to_dict() and look for your property. If it is not there, you spelled it wrong.
Layout properties vs. trace properties. Some properties live on the trace (marker.color, line.width, mode) and some live on the layout (xaxis.title, legend.orientation, paper_bgcolor). Confusing them produces errors. Fix: the Plotly reference documentation is the source of truth; when in doubt, look up the property.
Subplot traces not appearing. If you add traces to a make_subplots figure without specifying row and col, they all go to the first subplot by default. Fix: always pass row and col when adding traces to a multi-subplot figure.
Hovermode not applying. You set hovermode="x unified" but the tooltips still appear per-trace. Fix: hovermode is a layout property, so use fig.update_layout(hovermode="x unified"), not fig.update_traces(...).
Color formats. Plotly accepts hex strings ("#1f77b4"), named colors ("red"), RGB strings ("rgb(31,119,180)"), and RGBA ("rgba(31,119,180,0.5)"). It does not accept Python tuples or matplotlib color objects. Fix: convert your color to a string before passing it.
Animation frames not playing. You built frames, but the play button does nothing. Common cause: no updatemenus entry with an animate button. Fix: add updatemenus=[dict(type="buttons", buttons=[dict(label="Play", method="animate", args=[None])])] to the layout.
Legend overflow. Too many traces crowd the legend off the page. Fix: set legend=dict(orientation="h", y=1.02) to make it horizontal above the plot, or turn it off entirely with showlegend=False and rely on direct labeling.
Static export fails with kaleido error. The error usually says "Kaleido timed out" or "Kaleido failed to start." Fix: update kaleido to the latest version, and if running in a restricted environment (Docker, CI), ensure the container has the dependencies kaleido needs (Chromium or equivalent).
When in doubt, the first debugging step is always print(fig.to_dict()). Seeing the full spec as a dict makes it obvious where the mismatch is between what you wrote and what Plotly interpreted. The second step is the reference documentation — Plotly's docs are thorough, and most mysteries resolve quickly when you read the property's specification directly.
21.20 Graph Objects in a Larger Workflow
Plotly Graph Objects rarely lives in isolation. Real projects use it as one piece of a larger workflow that includes data preparation, analysis, and delivery.
Data preparation happens in pandas, before the chart code runs. You clean the DataFrame, aggregate, reshape, and compute derived columns. A well-prepared DataFrame turns a complex Plotly call into a simple one — the chart code becomes about display, not about calculation. This is the tidy-data principle from Chapter 16 applied to a different library.
Analysis happens alongside the visualization. Often you compute summary statistics, fit models, or run tests in parallel with making the chart. The Plotly hover tooltip can display these derived values through customdata, letting the reader see the raw data, the model's prediction, and the residual all in one place.
Delivery happens after the figure is built. For notebook use, fig.show() is enough. For a dashboard, the figure becomes a component of a Dash or Streamlit app (Chapters 29-30). For a report, export to HTML (for interactive) or PNG (for static) with kaleido. For email or Slack, static PNG is usually the right choice because interactive HTML does not embed well in these channels.
Testing and reproducibility are worth a word. Plotly figures are hard to snapshot-test because the JSON spec includes small, non-meaningful differences across environments (float precision, trace ordering, auto-generated IDs). The pragmatic approach is to test the data that feeds the figure, not the figure itself. If your input DataFrame is correct, and the chart-building code is simple enough to review by eye, the figure is almost certainly correct. For figures that must match exact pixel output, render to PNG and compare with a tolerance — the standard visual-regression testing workflow.
The Graph Objects API you learn in this chapter is thus a piece of a larger toolkit — not the entire workflow. Combined with pandas for preparation, statsmodels for analysis, and Dash for deployment, it forms a complete Python interactive visualization stack. Chapter 22 adds one more tool to the kit: Altair, which takes a different approach to declarative visualization that complements rather than replaces Plotly.
21.21 Check Your Understanding
Before continuing to Chapter 22 (Altair), make sure you can answer:
- What are the three top-level components of a Plotly Figure?
- When should you use Plotly Graph Objects instead of Plotly Express?
- What does
make_subplotsprovide that Plotly Express'sfacet_coldoes not? - How do you create a dual-y-axis chart in Graph Objects, and what is the ethical warning about this chart type?
- What is the
updatemenuslayout property, and what kind of interactivity does it provide? - How do
add_annotation,add_hline, andadd_vrectdiffer? - How do you inspect the underlying dict representation of a figure, and why would you want to?
- Name three trace types and describe when each is appropriate.
If any of these are unclear, re-read the relevant section. Chapter 22 introduces Altair, which takes a fundamentally different approach to declarative visualization based on the grammar of graphics.
21.22 Chapter Summary
This chapter introduced Plotly Graph Objects as the lower-level API behind Plotly Express:
- A Figure is a nested data structure with three components:
data(a list of traces),layout(everything else), and optionalframesfor animation. - Trace types include
go.Scatter,go.Bar,go.Heatmap,go.Box,go.Violin,go.Contour,go.Pie,go.Sunburst,go.Scattergl,go.Parcoords,go.Sankey, and many more. Each has a dedicated set of parameters for its specific chart type. make_subplotsbuilds grid layouts with row/column spans, shared axes, mixed chart types, and secondary y-axes. Use it when Plotly Express'sfacet_colis not flexible enough.- Dual y-axes are created with
make_subplots(specs=[[{"secondary_y": True}]]). They are a powerful but ethically fraught chart type — use with care and prefer alternatives when the audience is general. - Interactive controls (buttons, dropdowns) are added via the
updatemenuslayout property. Each button has a method and args that describe how the figure should change when clicked. - Annotations and shapes (
add_annotation,add_hline,add_vline,add_hrect,add_vrect) add static overlays to a figure without disturbing the traces. - Custom colorscales are lists of
[value, color]pairs. Use them for brand consistency or for specific perceptual needs. - Debugging via
fig.to_dict()shows the full figure as a nested dictionary, making it easier to understand why a figure is (or is not) rendering as expected.
The chapter's threshold concept — figures are data structures — argues that Plotly's mental model is fundamentally different from matplotlib's. In matplotlib you issue draw commands against a canvas. In Plotly you describe a JSON spec and a JavaScript renderer builds the chart. Grasping this difference explains why Plotly's API feels declarative and data-driven while matplotlib's feels imperative and step-by-step.
Chapter 22 takes the declarative philosophy further by introducing Altair, a Python library built on the Vega-Lite visualization grammar. Altair charts are even more declarative than Plotly charts — they are specifications of what the visualization should look like, not lists of traces. The grammar-of-graphics approach will feel familiar if you have used seaborn (or ggplot2), and it provides a different kind of composability than Plotly offers.
21.23 Spaced Review
Questions that reach back to earlier chapters:
- From Chapter 10 (matplotlib Architecture): What is the analog of matplotlib's "Everything Is an Object" threshold concept in Plotly? Is it the same mental model or a different one?
- From Chapter 13 (Subplots & GridSpec): How does
make_subplotscompare to matplotlib'sGridSpec? What features does each API have that the other lacks? - From Chapter 4 (Honest Charts): The dual-y-axis chart is a documented ethical pitfall. When is it appropriate to use one, and how do you defend your use of it?
- From Chapter 7 (Typography): Action titles and annotations matter in Plotly the same way they matter in matplotlib. How do you set them in Graph Objects, and what restrictions does Plotly impose compared to matplotlib?
- From Chapter 20 (Plotly Express): When should you drop from Plotly Express to Graph Objects, and when should you stay in Plotly Express?
Plotly Graph Objects is the power-user API. It gives you control over every property, every trace, every layout element, and every interactive control. For the 80% of charts that Plotly Express handles well, stay with Plotly Express. For the 20% that require something more — custom layouts, mixed trace types, interactive controls, dual axes, specific annotations — Graph Objects is the tool. Chapter 22 leaves Plotly behind and introduces a completely different approach to interactive visualization: Altair's declarative grammar.