When Plotly Express Isn't Enough: Moving to Graph Objects
Stop fighting Plotly Express arguments. Use Graph Objects to add threshold lines, custom hover metadata, and granular trace control.
14 Oct 2025, 03:02 UTC

The Customization Wall
You start with Plotly Express because one line turns a Pandas DataFrame into a chart. Then the request changes: add a red threshold line at y = 14, color points by a category, and show a hidden "sensor status" field in the tooltip that is not plotted on any axis. You search the px.scatter arguments and find no clean way to inject that extra metadata or draw an arbitrary reference line without restructuring your data.
The practical takeaway is not to abandon Plotly Express. It is to treat px as a fast way to build the base figure, then switch to Plotly Graph Objects (go) for the specific additions Express cannot express.
What Plotly Express Hides
Plotly Express is a high-level wrapper. You pass it a tidy DataFrame—one row per observation, one column per variable—and it decides how many traces to create, assigns colors, and builds the legend. Those traces still exist inside the resulting figure, but Express manages them for you. If you need to touch one trace directly, or add a trace that does not map cleanly to a DataFrame column, the abstraction becomes an obstacle rather than a shortcut.
What Graph Objects Exposes
Plotly Graph Objects lets you build a go.Figure trace by trace. A trace is a single plotted series, such as a scatter series or a bar series. You define its x and y arrays, its visual style, and any auxiliary data separately. This is more verbose, but it is the only reliable path to mixed chart types, multiple y-axes, and custom hover content drawn from columns that are not mapped to axes.
Worked Example: Threshold Line and Hidden Metadata
Assumes Plotly 5.x and pandas are installed. Run this in a Python environment. No special permissions are required; the script only renders a figure in memory or opens a browser tab.
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
df = pd.DataFrame({
'timestamp': [1, 2, 3, 4, 5],
'reading': [10, 15, 13, 17, 12],
'sensor_id': ['S1', 'S1', 'S1', 'S1', 'S1'],
'status': ['ok', 'ok', 'alert', 'ok', 'alert']
})
# Base figure from Express
fig = px.scatter(df, x='timestamp', y='reading', title='Sensor Readings')
# Add a reference line using Graph Objects
fig.add_trace(go.Scatter(
x=[1, 5],
y=[14, 14],
mode='lines',
name='Limit',
line=dict(color='red', dash='dash')
))
# Attach hidden metadata to the scatter trace for hover labels
fig.update_traces(
customdata=df['status'],
hovertemplate='Reading: %{y}
Status: %{customdata}'
)
fig.show()
The customdata array carries the status column into the browser without plotting it. The hovertemplate reads that array at render time. The add_trace call adds the threshold line as a separate trace, something Express does not support through a dedicated argument.
Verification
After running the script, inspect the figure object in your Python console:
print(len(fig.data)) # Should print 2: one Express trace, one manual trace
print(fig.data[0].customdata) # Should show the status array
If customdata is empty or len(fig.data) is 1, the hover template will not display the status field and the threshold line is missing.
Trade-offs
| Concern | Plotly Express | Graph Objects |
|---|---|---|
| Code volume | Minimal | Higher |
| Trace-level control | Indirect | Direct |
| Best for | Rapid exploration | Custom dashboards and overlays |
One limitation is memory. Express can duplicate data internally when handling color groups and facets. For very large datasets—roughly above 100,000 points—browser rendering can lag regardless of the API you choose. In that range, prefer go.Scattergl, the WebGL-backed scatter trace, over the default SVG-based go.Scatter.
Closing Action
Next time a Plotly Express argument does not exist for the mark or annotation you need, stop searching the Express API. Build the base figure with px, then use fig.add_trace() and fig.update_traces() to add the exact trace and metadata your dashboard requires.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.