How to Add Annotations in Plotly
Annotations bridge the gap between raw data and actionable insights. A chart showing quarterly revenue is informative; the same chart with annotations marking product launches, market events, or...
Key Insights
- Plotly annotations add context to visualizations through text, arrows, and shapes positioned at specific coordinates, making complex data stories immediately clear to viewers
- The
add_annotation()method offers granular control over positioning, styling, and interactivity, with support for both data coordinates and paper coordinates for flexible placement - Strategic annotation placement—highlighting outliers, labeling peaks, and adding contextual notes—transforms static charts into self-explanatory narratives without requiring external documentation
Why Annotations Matter in Data Visualization
Annotations bridge the gap between raw data and actionable insights. A chart showing quarterly revenue is informative; the same chart with annotations marking product launches, market events, or anomalies tells a story. Plotly’s annotation system gives you programmatic control over these narrative elements, letting you guide viewers’ attention exactly where it needs to go.
I’ve seen too many dashboards where users squint at unlabeled peaks or miss critical outliers. Good annotations eliminate that friction. They’re especially valuable when you’re presenting to non-technical stakeholders who need immediate context without diving into the data themselves.
Basic Text Annotations
Let’s start with a simple line chart and add our first annotation. The add_annotation() method is your primary tool:
import plotly.graph_objects as go
import pandas as pd
# Sample data: monthly sales
dates = pd.date_range('2023-01-01', periods=12, freq='M')
sales = [45, 52, 48, 65, 70, 68, 72, 85, 78, 82, 88, 95]
fig = go.Figure()
fig.add_trace(go.Scatter(x=dates, y=sales, mode='lines+markers', name='Sales'))
# Add annotation for peak sales
fig.add_annotation(
x='2023-08-31',
y=85,
text="Peak Q3 Sales<br>New product launch",
showarrow=True,
arrowhead=2,
arrowsize=1,
arrowwidth=2,
arrowcolor="#636363",
ax=-40,
ay=-40
)
fig.update_layout(title="Monthly Sales Performance",
xaxis_title="Month",
yaxis_title="Sales ($K)")
fig.show()
The key parameters here are straightforward: x and y define where the annotation points, text contains your message, and showarrow=True adds the connecting arrow. The ax and ay parameters control arrow positioning relative to the text box—negative values move the text up and left.
Arrow Annotations and Styling
Annotations become powerful when you customize their appearance to match your visualization’s purpose. Here’s how to style multiple annotations for different data points:
fig = go.Figure()
fig.add_trace(go.Scatter(x=dates, y=sales, mode='lines+markers',
name='Sales', line=dict(color='#2E86AB')))
# Q1 dip annotation
fig.add_annotation(
x='2023-03-31', y=48,
text="Q1 Slowdown",
showarrow=True,
arrowhead=2,
arrowcolor="#A23B72",
font=dict(size=12, color="white"),
bgcolor="#A23B72",
bordercolor="#A23B72",
borderwidth=2,
borderpad=4,
ax=40, ay=-30
)
# Q3 peak annotation
fig.add_annotation(
x='2023-08-31', y=85,
text="Product Launch Impact",
showarrow=True,
arrowhead=3,
arrowsize=1.5,
arrowcolor="#F18F01",
font=dict(size=12, color="white"),
bgcolor="#F18F01",
bordercolor="#F18F01",
borderwidth=2,
borderpad=4,
ax=-50, ay=-40
)
# Year-end growth annotation
fig.add_annotation(
x='2023-12-31', y=95,
text="Record High",
showarrow=True,
arrowhead=2,
arrowcolor="#06A77D",
font=dict(size=12, color="white", family="Arial Black"),
bgcolor="#06A77D",
bordercolor="#06A77D",
borderwidth=2,
borderpad=4,
ax=-30, ay=30
)
fig.update_layout(title="Quarterly Sales Analysis with Key Events",
showlegend=True, height=500)
fig.show()
Notice how color-coding annotations by sentiment (red for problems, orange for changes, green for successes) creates an intuitive visual language. The borderpad parameter adds breathing room around text, while font customization ensures readability.
Programmatic Annotations Based on Data
Manually adding annotations works for a few key points, but what about automatically highlighting all values above a threshold? Here’s where loops and conditional logic shine:
import numpy as np
# Generate data with some outliers
np.random.seed(42)
x_data = list(range(50))
y_data = np.random.normal(50, 10, 50)
y_data[10] = 85 # Outlier
y_data[25] = 90 # Outlier
y_data[40] = 15 # Outlier
fig = go.Figure()
fig.add_trace(go.Scatter(x=x_data, y=y_data, mode='lines+markers',
name='Measurements'))
# Add threshold line
threshold_upper = 75
threshold_lower = 30
fig.add_hline(y=threshold_upper, line_dash="dash",
line_color="red", opacity=0.5)
fig.add_hline(y=threshold_lower, line_dash="dash",
line_color="red", opacity=0.5)
# Automatically annotate outliers
for i, (x, y) in enumerate(zip(x_data, y_data)):
if y > threshold_upper:
fig.add_annotation(
x=x, y=y,
text=f"High: {y:.1f}",
showarrow=True,
arrowhead=2,
arrowcolor="red",
font=dict(size=10, color="red"),
ax=0, ay=-20
)
elif y < threshold_lower:
fig.add_annotation(
x=x, y=y,
text=f"Low: {y:.1f}",
showarrow=True,
arrowhead=2,
arrowcolor="red",
font=dict(size=10, color="red"),
ax=0, ay=20
)
fig.update_layout(title="Automatic Outlier Detection and Annotation",
xaxis_title="Time", yaxis_title="Value")
fig.show()
This pattern is invaluable for quality control dashboards, anomaly detection, or any scenario where you need to flag exceptional values without manual intervention.
Annotations for Heatmaps and Complex Plots
Heatmaps benefit enormously from cell annotations showing actual values. Here’s how to add them effectively:
import plotly.express as px
# Correlation matrix example
data = {
'Revenue': [1.0, 0.85, 0.62, -0.23],
'Marketing': [0.85, 1.0, 0.71, -0.15],
'Support': [0.62, 0.71, 1.0, -0.08],
'Churn': [-0.23, -0.15, -0.08, 1.0]
}
df = pd.DataFrame(data, index=['Revenue', 'Marketing', 'Support', 'Churn'])
fig = px.imshow(df,
text_auto=True, # This adds value annotations
color_continuous_scale='RdBu_r',
aspect='auto',
labels=dict(color="Correlation"))
# Customize annotation appearance
fig.update_traces(texttemplate='%{z:.2f}', textfont_size=12)
# Add title annotation with context
fig.add_annotation(
text="Strong positive correlation between Revenue and Marketing spend",
xref="paper", yref="paper",
x=0.5, y=-0.1,
showarrow=False,
font=dict(size=11, color="#555"),
xanchor='center'
)
fig.update_layout(title="Customer Metrics Correlation Matrix")
fig.show()
The text_auto=True parameter in px.imshow() handles cell annotations automatically. For more control, use texttemplate to format numbers. The bottom annotation uses paper coordinates (xref="paper") instead of data coordinates, positioning it relative to the entire figure rather than the data range.
Annotations Across Subplots
Managing annotations across multiple subplots requires attention to the xref and yref parameters:
from plotly.subplots import make_subplots
fig = make_subplots(rows=2, cols=1,
subplot_titles=("Revenue", "Costs"))
# Add traces
fig.add_trace(go.Scatter(x=dates, y=sales, name="Revenue"),
row=1, col=1)
costs = [30, 35, 32, 42, 45, 43, 46, 52, 48, 50, 53, 55]
fig.add_trace(go.Scatter(x=dates, y=costs, name="Costs"),
row=2, col=1)
# Annotation for first subplot
fig.add_annotation(
x='2023-08-31', y=85,
text="Revenue Peak",
showarrow=True,
row=1, col=1,
arrowhead=2,
ax=-30, ay=-30
)
# Annotation for second subplot
fig.add_annotation(
x='2023-08-31', y=52,
text="Cost Increase",
showarrow=True,
row=2, col=1,
arrowhead=2,
ax=-30, ay=-30
)
fig.update_layout(height=600, showlegend=False)
fig.show()
The row and col parameters ensure annotations attach to the correct subplot axes.
Best Practices for Clean Annotations
The difference between helpful and distracting annotations comes down to restraint. Here are principles I follow:
Prioritize ruthlessly: Not every data point deserves an annotation. Highlight only what changes decisions or requires explanation. If you’re annotating more than 10-15% of your data points, you’re probably over-annotating.
Ensure contrast: Text must be readable against both the background and nearby data. Use bgcolor with sufficient opacity and choose font colors that pop. Test your visualizations at different screen sizes.
Use consistent styling: Establish a visual language—perhaps red annotations for problems, green for successes, blue for neutral observations. This helps viewers parse information faster.
Mind the positioning: The ax and ay parameters prevent annotations from covering data. Experiment with different values to find clean placement. For dense charts, consider using standoff to add distance between the arrow and the point.
Performance matters: Each annotation is a separate object. If you’re adding hundreds of annotations, consider whether a tooltip-based approach (using hovertemplate) might be more appropriate. I’ve seen dashboards grind to a halt because someone annotated every single data point.
Here’s a comparison showing restrained annotation:
# Better approach: annotate only key insights
fig = go.Figure()
fig.add_trace(go.Scatter(x=dates, y=sales, mode='lines+markers'))
# Only annotate the absolute peak and the recovery point
fig.add_annotation(x='2023-12-31', y=95, text="Record: $95K",
showarrow=True, ax=-40, ay=-30)
fig.add_annotation(x='2023-04-30', y=65, text="Recovery begins",
showarrow=True, ax=40, ay=-30)
fig.update_layout(title="Sales Trend with Key Milestones")
Annotations transform Plotly visualizations from data displays into communication tools. Master the basics of add_annotation(), understand coordinate systems, and apply styling purposefully. Most importantly, annotate with intention—every label should answer a question or highlight something your audience needs to know. When done right, your charts will tell their own stories without requiring a presentation deck to explain them.