How to Add Annotations in Matplotlib
Annotations transform raw data plots into communicative visualizations by explicitly highlighting important features. While basic plots show patterns, annotations direct your audience's attention to...
Key Insights
- Matplotlib’s
annotate()function combines text labels with arrows to point directly at data features, whiletext()only adds floating labels without visual connections - The
arrowpropsdictionary controls arrow appearance with styles ranging from simple lines to fancy curved arrows, and coordinate systems (xycoords,textcoords) let you position annotations relative to data points, axes, or the entire figure - Programmatic annotation through loops enables automatic labeling of outliers, peaks, or threshold violations, making your visualizations scale beyond manual point-by-point markup
Introduction to Matplotlib Annotations
Annotations transform raw data plots into communicative visualizations by explicitly highlighting important features. While basic plots show patterns, annotations direct your audience’s attention to specific insights—peak values, anomalies, trend changes, or critical thresholds.
Matplotlib offers two primary approaches for adding text to plots: plt.text() for simple labels and plt.annotate() for annotations with arrows. The distinction matters. Text labels float independently, leaving viewers to infer connections between labels and data points. Annotations explicitly link text to specific coordinates with visual indicators, eliminating ambiguity.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 100)
y = np.sin(x) * np.exp(-x/10)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
# Using text() - no connection to data point
ax1.plot(x, y)
ax1.text(2, 0.6, 'Peak region', fontsize=12)
ax1.set_title('plt.text() - Floating Label')
# Using annotate() - explicit connection
ax2.plot(x, y)
ax2.annotate('Peak region', xy=(1.5, 0.75), xytext=(3, 0.6),
arrowprops=dict(arrowstyle='->', color='red'),
fontsize=12)
ax2.set_title('plt.annotate() - Connected Annotation')
plt.tight_layout()
plt.show()
The left plot requires viewers to guess which part of the curve the label references. The right plot removes all doubt with a directed arrow.
Basic Text Annotations with annotate()
The annotate() function requires at minimum two parameters: the annotation text and the xy coordinate tuple specifying the point being annotated. By default, the text appears directly at the specified coordinate.
import matplotlib.pyplot as plt
import numpy as np
# Generate data with a clear peak
x = np.linspace(0, 10, 100)
y = 3 + 2*np.sin(x) - 0.1*x
# Find the maximum point
max_idx = np.argmax(y)
max_x, max_y = x[max_idx], y[max_idx]
plt.figure(figsize=(10, 6))
plt.plot(x, y, linewidth=2, color='steelblue')
# Basic annotation pointing to the peak
plt.annotate(f'Maximum: {max_y:.2f}',
xy=(max_x, max_y),
xytext=(max_x + 1.5, max_y - 0.5),
arrowprops=dict(arrowstyle='->'),
fontsize=12,
color='darkred')
plt.xlabel('X axis')
plt.ylabel('Y axis')
plt.title('Line Plot with Basic Annotation')
plt.grid(True, alpha=0.3)
plt.show()
The xytext parameter positions the text box separately from the arrow’s target point, preventing text from obscuring data. This separation becomes critical in dense visualizations.
Customizing Arrow Styles and Properties
The arrowprops dictionary unlocks extensive arrow customization. The arrowstyle parameter accepts several values: '-' (line), '->' (arrow), '-[' (bracket), '-|>' (fancy arrow), and 'wedge'. Each serves different visual purposes.
import matplotlib.pyplot as plt
import numpy as np
np.random.seed(42)
x = np.random.rand(50) * 10
y = np.random.rand(50) * 10
fig, ax = plt.subplots(figsize=(12, 8))
ax.scatter(x, y, s=50, alpha=0.6, color='steelblue')
# Demonstrate different arrow styles
arrow_styles = [
('-', 'Simple line'),
('->', 'Standard arrow'),
('-[', 'Bracket'),
('fancy', 'Fancy arrow'),
('wedge', 'Wedge')
]
for idx, (style, label) in enumerate(arrow_styles):
point_idx = idx * 8
ax.annotate(label,
xy=(x[point_idx], y[point_idx]),
xytext=(2, 9 - idx*1.5),
arrowprops=dict(
arrowstyle=style,
color=plt.cm.Set1(idx),
linewidth=2,
connectionstyle='arc3,rad=0.3'
),
fontsize=11,
bbox=dict(boxstyle='round,pad=0.5', facecolor='white', alpha=0.8))
ax.set_xlim(-1, 11)
ax.set_ylim(-1, 11)
ax.set_title('Arrow Style Comparison', fontsize=14, fontweight='bold')
plt.show()
The connectionstyle parameter curves arrows, with 'arc3,rad=0.3' creating gentle curves. Larger rad values produce more dramatic curves, useful when multiple annotations converge on similar regions.
Advanced Positioning with Coordinate Systems
Matplotlib’s coordinate system flexibility prevents annotation chaos. The xycoords parameter defines the coordinate system for the annotated point, while textcoords defines the system for text placement. Options include 'data' (default, uses axis values), 'axes fraction' (0-1 relative to axes), and 'figure fraction' (0-1 relative to entire figure).
import matplotlib.pyplot as plt
import numpy as np
categories = ['Q1', 'Q2', 'Q3', 'Q4']
values = [23, 45, 38, 52]
fig, ax = plt.subplots(figsize=(10, 6))
bars = ax.bar(categories, values, color='teal', alpha=0.7)
# Data coordinates for the point
ax.annotate('Highest\nQuarter',
xy=(3, 52),
xytext=(0.7, 0.5),
xycoords='data',
textcoords='axes fraction',
arrowprops=dict(arrowstyle='->', color='red', linewidth=2),
fontsize=12,
ha='center')
# Axes fraction for consistent positioning
ax.annotate('Performance Metrics',
xy=(0.5, 0.95),
xycoords='axes fraction',
fontsize=14,
ha='center',
fontweight='bold',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
ax.set_ylabel('Revenue (millions)')
ax.set_title('Quarterly Revenue with Mixed Coordinate Annotations')
plt.show()
Using 'axes fraction' for text placement ensures annotations remain positioned correctly even when data scales change. This approach shines in automated reporting where data ranges vary between runs.
Annotating Multiple Points Programmatically
Manual annotation doesn’t scale. Looping through data with conditional logic enables intelligent, automatic annotation of significant points.
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
# Simulate time series data
dates = pd.date_range('2024-01-01', periods=100, freq='D')
values = 100 + np.cumsum(np.random.randn(100)) + 0.5*np.arange(100)
threshold = 130
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(dates, values, linewidth=2, color='navy', label='Daily Values')
ax.axhline(threshold, color='red', linestyle='--', linewidth=1.5,
label=f'Threshold: {threshold}')
# Annotate points exceeding threshold
violations = values > threshold
violation_indices = np.where(violations)[0]
# Only annotate every 5th violation to avoid clutter
for idx in violation_indices[::5]:
ax.annotate(f'{values[idx]:.1f}',
xy=(dates[idx], values[idx]),
xytext=(10, 15),
textcoords='offset points',
arrowprops=dict(arrowstyle='->', color='red', linewidth=1),
fontsize=9,
color='darkred',
bbox=dict(boxstyle='round,pad=0.3',
facecolor='yellow', alpha=0.7))
# Annotate global maximum
max_idx = np.argmax(values)
ax.annotate(f'Peak: {values[max_idx]:.1f}',
xy=(dates[max_idx], values[max_idx]),
xytext=(30, -30),
textcoords='offset points',
arrowprops=dict(arrowstyle='->', color='green',
linewidth=2, connectionstyle='arc3,rad=0.2'),
fontsize=11,
fontweight='bold',
bbox=dict(boxstyle='round', facecolor='lightgreen', alpha=0.8))
ax.set_xlabel('Date')
ax.set_ylabel('Value')
ax.set_title('Automated Threshold Violation Annotations')
ax.legend()
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
The 'offset points' coordinate system for textcoords positions text relative to the data point using pixel offsets, ensuring consistent spacing regardless of axis scaling.
Styling and Best Practices
Professional annotations balance information density with readability. The bbox parameter adds background boxes that improve text legibility, especially over complex plots.
import matplotlib.pyplot as plt
import numpy as np
# Create a complex background
x = np.linspace(0, 10, 100)
y1 = np.sin(x)
y2 = np.cos(x)
y3 = np.sin(x) * np.cos(x)
fig, ax = plt.subplots(figsize=(12, 7))
ax.plot(x, y1, label='sin(x)', linewidth=2)
ax.plot(x, y2, label='cos(x)', linewidth=2)
ax.plot(x, y3, label='sin(x)·cos(x)', linewidth=2)
# Professional annotation with full styling
ax.annotate('First Intersection',
xy=(0.785, 0.707),
xytext=(2, 0.3),
arrowprops=dict(
arrowstyle='->',
color='black',
linewidth=2,
connectionstyle='arc3,rad=0.3',
shrinkA=0,
shrinkB=5
),
fontsize=11,
fontweight='bold',
color='darkblue',
bbox=dict(
boxstyle='round,pad=0.7',
facecolor='white',
edgecolor='darkblue',
linewidth=2,
alpha=0.95
),
ha='center',
va='center')
# Multiple annotations with consistent styling
critical_points = [(np.pi/2, 1, 'Peak'), (np.pi, 0, 'Zero Crossing')]
for xp, yp, label in critical_points:
ax.annotate(label,
xy=(xp, yp),
xytext=(15, 15),
textcoords='offset points',
arrowprops=dict(arrowstyle='->', color='gray', linewidth=1.5),
fontsize=10,
bbox=dict(boxstyle='round', facecolor='lightyellow',
edgecolor='gray', alpha=0.9))
ax.legend(loc='upper right')
ax.grid(True, alpha=0.3)
ax.set_xlabel('X axis', fontsize=12)
ax.set_ylabel('Y axis', fontsize=12)
ax.set_title('Professionally Styled Annotations', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
Key styling principles: use bbox backgrounds over busy plots, maintain consistent fontsize hierarchies (title > annotations > labels), limit arrow colors to 2-3 per plot, and use shrinkA and shrinkB in arrowprops to prevent arrows from overlapping markers or text boxes.
Avoid annotation overlap by spacing xytext positions deliberately or using algorithms like adjustText library for automatic positioning. When annotating dense data, prioritize the most significant points—not every point needs annotation. Strategic annotation guides viewers through your narrative without overwhelming them.