On this page
Advanced Plotly charts are the tools for when one line or bar is not enough: make_subplots for several panels, secondary_y for two scales, animation_frame for change over time, and annotations for a chart that explains itself. Each is a few lines of code. The hard part is knowing when each one misleads.
Take animation. Copy the axis ranges from Plotly's own gapminder example and Plotly draws four data points (Kuwait in three years, Rwanda in one) off the chart, with no warning. This part of the series is for readers who already know Plotly Express from part one. You get runnable code for subplots, dual axes, animation, 3D, heatmaps and annotation, plus a table for picking the right one.
Every example below was run on Plotly 6.3. As of September 2026 the current release is Plotly 7.1, and nothing used here was removed in 7.0.
How do you build subplots in Plotly?
Multi-panel figures need make_subplots from plotly.subplots, as the subplots guide shows:
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import numpy as np
x = np.linspace(0, 10, 100)
fig = make_subplots(
rows=2, cols=2,
subplot_titles=('Sine', 'Cosine', 'Damped', 'Noise'),
horizontal_spacing=0.10,
vertical_spacing=0.14,
)
fig.add_trace(go.Scatter(x=x, y=np.sin(x), name='sin'), row=1, col=1)
fig.add_trace(go.Scatter(x=x, y=np.cos(x), name='cos'), row=1, col=2)
fig.add_trace(go.Scatter(x=x, y=np.sin(x) * np.exp(-x / 5), name='damped'), row=2, col=1)
rng = np.random.default_rng(0)
fig.add_trace(go.Scatter(x=x, y=rng.normal(size=100), mode='markers', name='noise'),
row=2, col=2)
fig.update_layout(height=600, showlegend=False, template='plotly_white',
title_text='Four panels')
fig.show()
Axis updates also take the same row/col arguments:
fig.update_yaxes(title_text='Amplitude', row=1, col=1)
fig.update_xaxes(title_text='Time (s)', row=2, col=1)
If stacked panels share a time axis, pass shared_xaxes=True to make_subplots. Panels in the same column are then linked, so zooming one zooms the others, and only the bottom panel keeps its x tick labels.
When should you use a secondary y-axis?
Use one only for two series on genuinely different scales. The multiple axes guide uses the same pattern:
fig = make_subplots(specs=[[{'secondary_y': True}]])
months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']
revenue = [120, 145, 132, 178, 195, 210]
margin = [0.22, 0.24, 0.21, 0.27, 0.29, 0.28]
fig.add_trace(go.Bar(x=months, y=revenue, name='Revenue',
marker_color='#2563eb'), secondary_y=False)
fig.add_trace(go.Scatter(x=months, y=margin, name='Margin',
mode='lines+markers',
line={'color': '#f59e0b', 'width': 3}), secondary_y=True)
fig.update_yaxes(title_text='Revenue (₹k)', secondary_y=False)
fig.update_yaxes(title_text='Margin', tickformat='.0%', secondary_y=True)
fig.update_layout(template='plotly_white', hovermode='x unified')
fig.show()
A caution worth stating: dual axes let you make any two series look correlated by choosing the scales. Therefore, use them when the units really differ and the comparison is legitimate, and be suspicious when you see them elsewhere.
How do you animate a Plotly chart without the axes jumping?
Pass animation_frame to a Plotly Express function and fix range_x and range_y yourself. The single argument creates the animation; the fixed ranges are what make it readable:
import plotly.express as px
gap = px.data.gapminder()
fig = px.scatter(
gap,
x='gdpPercap', y='lifeExp',
size='pop', color='continent',
hover_name='country',
animation_frame='year',
animation_group='country',
log_x=True, size_max=55,
range_x=[100, 150_000], range_y=[20, 90],
title='Development over time',
)
fig.show()
animation_group keeps each country's point identified across frames, so it moves rather than disappearing and reappearing. The explicit range_x and range_y matter just as much: without them the axes rescale each frame and the motion becomes meaningless.
Worked example: choosing the ranges
The Plotly animation guide advises always fixing the ranges so the data stays visible, and its own example uses [100, 100000] and [25, 90]. However, check your data first:
gap['gdpPercap'].max() # 113523.1329 - Kuwait, 1957
gap['lifeExp'].min() # 23.599 - Rwanda, 1992
Both values fall outside those example ranges, so three Kuwait frames and one Rwanda frame would be drawn off the chart. That is why the example above uses the wider [100, 150_000] and [20, 90], which cover all 12 frames from 1952 to 2007.
When is a 3D Plotly chart worth using?
Only when the third dimension is the message, such as the surface of a mathematical function or genuinely spatial data. For everything else, a 2D chart with colour, size or facets reads better:
x = np.linspace(-5, 5, 60)
y = np.linspace(-5, 5, 60)
X, Y = np.meshgrid(x, y)
Z = np.sin(np.sqrt(X**2 + Y**2))
fig = go.Figure(go.Surface(x=X, y=Y, z=Z, colorscale='Viridis'))
fig.update_layout(
title='A surface',
scene={'xaxis_title': 'x', 'yaxis_title': 'y', 'zaxis_title': 'z'},
height=600,
)
fig.show()
Surfaces of a genuine mathematical function are a fair use, because the shape is the point. By contrast, a 3D bar chart of categorical data is almost never better than a heatmap or a faceted 2D chart, because the perspective distorts comparison and near objects hide far ones.
Why is annotation the most useful advanced Plotly feature?
A chart that states its own point is worth several that leave the reader to find it, so this is where extra effort pays off first.
fig = go.Figure(go.Scatter(x=months, y=revenue, mode='lines+markers',
line={'color': '#2563eb', 'width': 3}))
fig.add_annotation(
x='Apr', y=178,
text='Pricing change<br>launched',
showarrow=True, arrowhead=2, ax=-50, ay=-60,
bgcolor='white', bordercolor='#cbd5e1', borderwidth=1, borderpad=6,
)
fig.add_vrect(x0='Apr', x1='Jun', fillcolor='#2563eb', opacity=0.06,
line_width=0, annotation_text='Post-change')
fig.add_hline(y=150, line_dash='dash', line_color='#94a3b8',
annotation_text='Target')
fig.update_layout(template='plotly_white', title='Revenue, with context')
fig.show()
add_vrect, add_hline and add_annotation between them cover most of what you need to make a chart self-explanatory. The shapes guide lists the matching add_vline and add_hrect, plus annotation_position for placing the label. On a category axis like this one, the month names work directly as positions.
How do you make a correlation heatmap in Plotly?
Use px.imshow on a correlation matrix, with zmin=-1 and zmax=1 so the neutral colour means zero. It is also the usual answer when you were reaching for 3D:
corr = gap[['lifeExp', 'pop', 'gdpPercap', 'year']].corr()
fig = px.imshow(
corr,
text_auto='.2f',
color_continuous_scale='RdBu_r',
zmin=-1, zmax=1,
title='Correlation matrix',
)
fig.show()
On the gapminder data, the strongest pairing is life expectancy with GDP per capita, at 0.58. Setting zmin and zmax symmetrically around zero matters for diverging scales; otherwise, the colour midpoint drifts and the chart misleads. The text_auto format string follows d3-format, as the heatmaps guide notes.
Which advanced Plotly chart should you use?
| You want to show | Use | Avoid |
|---|---|---|
| Different charts side by side | make_subplots |
several separate figures |
| The same chart per category | facet_col in Plotly Express |
hand-built subplots |
| Two units on one timeline | secondary_y=True |
dual axes for same-unit data |
| Change over time | animation_frame with fixed ranges |
auto-scaling axes |
| A third numeric variable | colour, size or a heatmap | 3D bars |
| A mathematical surface | go.Surface |
a flat contour if shape matters |
| The takeaway | add_annotation, add_vrect, add_hline |
a long caption |
Common mistakes with advanced Plotly charts
- Forgetting
rowandcol. Traces added without them do not go where you intended, so always pass both. - Leaving animation ranges automatic. The axes jump between frames; fix both ranges.
- Copying example ranges without checking the data. Points outside the range silently vanish.
- Using a diverging colour scale without
zmin/zmax. The neutral colour stops meaning zero. - Styling before annotating. One clear annotation usually matters more than colour tweaks.
A practical default for presentable charts
Build with Express, style lightly, annotate once. In practice, the sequence that produces good advanced Plotly charts quickly is this: first build the chart with Express, then set template='plotly_white' and hovermode='x unified' on anything time-based. Finally, add one annotation naming the insight, and stop there, since further styling has rapidly diminishing returns.
The Sunday Growth Brief
One email a week: the best new comparisons, a fresh roadmap and the tech news worth your attention.
No spam. Unsubscribe in one click.
Related reading
Plotly Part 1 covers the basics. For static, print-quality output, see matplotlib styling and customisation. The arrays behind these examples are explained in NumPy Part 3 on broadcasting, and the planned guide to Plotly maps, treemaps and 3D plots goes further into specialist charts.
Frequently asked questions
How do I put two charts side by side in Plotly?
Use plotly.subplots.make_subplots(rows=1, cols=2), then add each trace with the row and col arguments. Plotly Express cannot merge two finished figures into one, so arbitrary multi-panel work generally means Graph Objects. The exception is small multiples of the same chart, where facet_col in Plotly Express is far shorter than building subplots by hand.
How do I add a second y-axis in Plotly?
Pass specs=[[{'secondary_y': True}]] to make_subplots, then add each trace with secondary_y=True or secondary_y=False, and title each axis with update_yaxes using the same argument. Use it sparingly, because dual axes make it easy to imply a relationship that is not there simply by choosing the two scales.
Are 3D plots in Plotly useful?
Rarely. Occlusion and perspective make values hard to compare, and a static screenshot loses the rotation that made the chart readable on screen. Prefer colour, size or faceting to encode a third variable. Keep 3D for genuinely spatial data or for surfaces of a mathematical function, where the shape itself is the message.
Why does my Plotly animation look wrong?
Usually the axes are rescaling on every frame, so points appear to jump even when the data barely changes. Set range_x and range_y wide enough to cover every frame. Also pass animation_group so each entity keeps its identity between frames and moves smoothly instead of disappearing and reappearing.
Written by
Vanshika Nigam
ETL & Data Engineer, Accenture
ETL developer at Accenture working with Informatica, Snowflake and DBT, specialising in data mapping, cleansing and pipeline performance.

