How to Add a Legend in Matplotlib
Legends transform raw plots into comprehensible data stories. Without them, viewers are left guessing which line represents which dataset, which color maps to which category. A well-placed legend is...
Key Insights
- Matplotlib legends require explicit labels set via the
labelparameter in plotting functions—without labels,plt.legend()creates an empty legend - Position legends outside the plot area using
bbox_to_anchorcombined withlocto prevent data occlusion, especially critical for dense visualizations - For complex plots with multiple data series, use
ncolparameter and manual handles to create organized, multi-column legends that maintain readability
Introduction to Matplotlib Legends
Legends transform raw plots into comprehensible data stories. Without them, viewers are left guessing which line represents which dataset, which color maps to which category. A well-placed legend is the difference between a professional visualization and an ambiguous mess.
Consider these two plots:
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 100)
y1 = np.sin(x)
y2 = np.cos(x)
# Without legend
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.plot(x, y1, 'b-')
ax1.plot(x, y2, 'r-')
ax1.set_title('Without Legend')
# With legend
ax2.plot(x, y1, 'b-', label='sin(x)')
ax2.plot(x, y2, 'r-', label='cos(x)')
ax2.legend()
ax2.set_title('With Legend')
plt.tight_layout()
plt.show()
The left plot forces viewers to deduce meaning from context. The right plot communicates instantly. This is why legends matter.
Basic Legend Creation
Matplotlib generates legends automatically when you provide labels to your plot elements. The label parameter is your primary tool—set it when creating plots, then call legend() to render.
Here’s the fundamental pattern:
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 100)
# Single line with legend
plt.figure(figsize=(8, 5))
plt.plot(x, np.sin(x), label='Sine Wave')
plt.legend()
plt.title('Single Line Plot')
plt.show()
For multiple series, label each one:
# Multiple lines with automatic legend
plt.figure(figsize=(10, 6))
plt.plot(x, np.sin(x), 'b-', linewidth=2, label='sin(x)')
plt.plot(x, np.cos(x), 'r--', linewidth=2, label='cos(x)')
plt.plot(x, np.sin(x) * np.cos(x), 'g:', linewidth=2, label='sin(x)·cos(x)')
plt.legend()
plt.xlabel('x')
plt.ylabel('y')
plt.title('Trigonometric Functions')
plt.grid(True, alpha=0.3)
plt.show()
The legend() method scans all plot elements with labels and automatically generates entries. No labels means no legend entries—this is the most common beginner mistake.
You can use either plt.legend() for pyplot-style code or ax.legend() for object-oriented approaches. They’re functionally equivalent:
fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(x, np.sin(x), label='sin(x)')
ax.plot(x, np.cos(x), label='cos(x)')
ax.legend() # Object-oriented approach
plt.show()
Customizing Legend Appearance
Default legend placement often obscures data. Matplotlib provides extensive customization options to fix this.
Positioning
The loc parameter accepts string descriptors or numeric codes:
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
x = np.linspace(0, 10, 100)
positions = ['upper right', 'upper left', 'lower left', 'lower right']
for ax, loc in zip(axes.flat, positions):
ax.plot(x, np.sin(x), label='sin(x)')
ax.plot(x, np.cos(x), label='cos(x)')
ax.legend(loc=loc)
ax.set_title(f'loc="{loc}"')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
For precise control, use bbox_to_anchor to place legends outside the plot area:
plt.figure(figsize=(10, 6))
plt.plot(x, np.sin(x), label='sin(x)')
plt.plot(x, np.cos(x), label='cos(x)')
plt.plot(x, np.tan(x), label='tan(x)')
# Place legend outside to the right
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
plt.xlabel('x')
plt.ylabel('y')
plt.title('Legend Outside Plot Area')
plt.ylim(-2, 2)
plt.tight_layout()
plt.show()
The bbox_to_anchor tuple (1.05, 1) positions the legend just beyond the right edge at the top. This prevents data occlusion entirely.
Styling Options
Control appearance with these key parameters:
plt.figure(figsize=(10, 6))
plt.plot(x, np.sin(x), label='sin(x)', linewidth=2)
plt.plot(x, np.cos(x), label='cos(x)', linewidth=2)
plt.legend(
loc='upper right',
fontsize=12,
frameon=True, # Show frame
fancybox=True, # Rounded corners
shadow=True, # Drop shadow
framealpha=0.9, # Background transparency
edgecolor='black', # Frame color
facecolor='wheat' # Background color
)
plt.title('Styled Legend')
plt.grid(True, alpha=0.3)
plt.show()
For publication-quality plots, I typically use minimal styling:
plt.legend(
loc='best',
fontsize=10,
frameon=True,
framealpha=0.95,
edgecolor='gray'
)
The 'best' location lets Matplotlib choose the least obtrusive position automatically.
Advanced Legend Techniques
Sometimes automatic legend generation isn’t sufficient. Manual control gives you complete flexibility.
Custom Legend Handles
Create legend entries that don’t correspond to actual plot elements:
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 6))
# Plot data without labels
x = np.linspace(0, 10, 100)
ax.plot(x, np.sin(x), 'b-', linewidth=2)
ax.plot(x, np.cos(x), 'r-', linewidth=2)
ax.plot(x, np.sin(2*x), 'b--', linewidth=2)
ax.plot(x, np.cos(2*x), 'r--', linewidth=2)
# Create custom legend
solid_line = mpatches.Patch(color='black', label='Fundamental')
dashed_line = mpatches.Patch(color='gray', label='Second Harmonic')
blue_patch = mpatches.Patch(color='blue', label='Sine Family')
red_patch = mpatches.Patch(color='red', label='Cosine Family')
ax.legend(handles=[solid_line, dashed_line, blue_patch, red_patch],
loc='upper right')
ax.set_title('Custom Legend Handles')
plt.show()
Multiple Legends
Add multiple legends to separate different categorizations:
fig, ax = plt.subplots(figsize=(10, 6))
# First set of plots
line1 = ax.plot(x, np.sin(x), 'b-', linewidth=2, label='sin(x)')[0]
line2 = ax.plot(x, np.cos(x), 'r-', linewidth=2, label='cos(x)')[0]
# First legend
legend1 = ax.legend(loc='upper right')
# Second set of plots
line3 = ax.plot(x, np.sin(2*x), 'b--', linewidth=2, label='2ω')[0]
line4 = ax.plot(x, np.cos(2*x), 'r--', linewidth=2, label='2ω')[0]
# Second legend - must manually add first legend back
ax.add_artist(legend1)
ax.legend([line3, line4], ['High Frequency', 'High Frequency Alt'],
loc='lower left')
plt.title('Multiple Legends')
plt.show()
Scatter Plot Legends
Scatter plots require different handling:
fig, ax = plt.subplots(figsize=(10, 6))
# Generate sample data
np.random.seed(42)
categories = ['Category A', 'Category B', 'Category C']
colors = ['red', 'blue', 'green']
markers = ['o', 's', '^']
for category, color, marker in zip(categories, colors, markers):
x_data = np.random.randn(50)
y_data = np.random.randn(50)
ax.scatter(x_data, y_data, c=color, marker=marker,
s=100, alpha=0.6, label=category)
ax.legend(loc='best', fontsize=11)
ax.set_title('Scatter Plot with Legend')
ax.grid(True, alpha=0.3)
plt.show()
Common Legend Patterns and Best Practices
When dealing with many data series, legends become cluttered. Use multi-column layouts:
fig, ax = plt.subplots(figsize=(12, 6))
# Simulate 12 data series
x = np.linspace(0, 10, 100)
for i in range(12):
ax.plot(x, np.sin(x + i * 0.5), label=f'Series {i+1}')
# Multi-column legend below the plot
ax.legend(
bbox_to_anchor=(0.5, -0.15),
loc='upper center',
ncol=4, # 4 columns
fontsize=9,
frameon=False
)
ax.set_title('Multi-Column Legend Layout')
plt.subplots_adjust(bottom=0.2) # Make room for legend
plt.show()
Best practices I follow:
-
Always label your plots - Even if you think it’s obvious, add labels. Future you will thank present you.
-
Place legends outside for busy plots - Use
bbox_to_anchor=(1.05, 1)to avoid covering data. Adjust figure size accordingly withplt.subplots_adjust()orplt.tight_layout(). -
Use
loc='best'for simple plots - Let Matplotlib optimize placement when you have few series. -
Limit legend entries - If you have more than 8-10 items, consider splitting into multiple plots or using color bars for continuous data.
-
Match legend order to visual hierarchy - Order legend entries to match the natural reading of your plot (top to bottom, left to right).
-
Test on different backgrounds - Set
framealphaappropriately so legends remain readable regardless of what’s behind them.
Here’s a production-ready example incorporating these principles:
fig, ax = plt.subplots(figsize=(10, 6))
x = np.linspace(0, 10, 100)
metrics = {
'Response Time (ms)': (np.sin(x) * 50 + 100, 'blue', '-'),
'Error Rate (%)': (np.cos(x) * 2 + 5, 'red', '--'),
'Throughput (req/s)': (np.sin(x * 2) * 200 + 1000, 'green', '-.')
}
for label, (data, color, style) in metrics.items():
ax.plot(x, data, color=color, linestyle=style,
linewidth=2, label=label)
ax.legend(
loc='upper left',
fontsize=10,
frameon=True,
framealpha=0.95,
edgecolor='#cccccc'
)
ax.set_xlabel('Time (minutes)', fontsize=11)
ax.set_ylabel('Value', fontsize=11)
ax.set_title('System Performance Metrics', fontsize=13, fontweight='bold')
ax.grid(True, alpha=0.3, linestyle='--')
plt.tight_layout()
plt.show()
Legends are not decorative—they’re functional necessities. Master their placement and styling, and your visualizations will communicate clearly and professionally.