How to Create a Dual-Axis Plot in Matplotlib

Dual-axis plots display two datasets with different units or scales on a single chart, using separate y-axes on the left and right sides. The classic example is plotting temperature and rainfall over...

Key Insights

  • Dual-axis plots use twinx() to overlay two datasets with different scales, but should only be used when the variables share a meaningful relationship and time axis
  • Color-coordinate everything—line colors, axis labels, and tick marks—to help readers instantly distinguish between the two datasets without constantly referring to the legend
  • Most dual-axis plots are misleading; consider subplots or normalized scales instead unless you have a compelling reason to show correlation between variables on the same chart

Understanding When to Use Dual-Axis Plots

Dual-axis plots display two datasets with different units or scales on a single chart, using separate y-axes on the left and right sides. The classic example is plotting temperature and rainfall over time—one measured in degrees, the other in millimeters.

Here’s the reality: dual-axis plots are frequently misused. They’re tempting because they save space and suggest correlation, but they often mislead viewers by allowing you to manipulate scales to show whatever relationship you want. Use them sparingly, and only when both variables genuinely relate to the same x-axis category (usually time) and when showing their relationship adds analytical value.

Valid use cases include revenue versus profit margin, website traffic versus conversion rate, or stock price versus trading volume. Invalid use cases include forcing unrelated metrics together just to save dashboard space.

Creating Your First Dual-Axis Plot

The twinx() method creates a twin axes sharing the x-axis but with an independent y-axis on the right side. Here’s the fundamental pattern:

import matplotlib.pyplot as plt
import numpy as np

# Sample data: website metrics over 12 months
months = np.arange(1, 13)
page_views = np.array([45000, 47000, 52000, 49000, 55000, 58000, 
                       62000, 59000, 64000, 68000, 71000, 75000])
conversion_rate = np.array([2.3, 2.5, 2.4, 2.7, 2.8, 3.1, 
                           3.0, 3.2, 3.4, 3.3, 3.6, 3.8])

fig, ax1 = plt.subplots(figsize=(10, 6))

# Primary axis (left) - page views
ax1.plot(months, page_views, marker='o')
ax1.set_xlabel('Month')
ax1.set_ylabel('Page Views')
ax1.set_xlim(0.5, 12.5)

# Secondary axis (right) - conversion rate
ax2 = ax1.twinx()
ax2.plot(months, conversion_rate, marker='s', color='orange')
ax2.set_ylabel('Conversion Rate (%)')

plt.title('Website Traffic vs Conversion Rate')
plt.tight_layout()
plt.show()

The key is storing both axis objects (ax1 and ax2). The primary axis ax1 comes from subplots(), while the secondary axis ax2 comes from calling twinx() on the primary axis. Both share the same x-axis, but each has independent y-axis scaling.

Color-Coding for Clarity

Without visual differentiation, readers can’t tell which line corresponds to which axis. The solution is comprehensive color coordination across lines, labels, and tick marks:

fig, ax1 = plt.subplots(figsize=(10, 6))

# Define colors
color_primary = '#2E86AB'
color_secondary = '#A23B72'

# Primary axis styling
ax1.plot(months, page_views, marker='o', color=color_primary, 
         linewidth=2, label='Page Views')
ax1.set_xlabel('Month', fontsize=12, fontweight='bold')
ax1.set_ylabel('Page Views', fontsize=12, color=color_primary, 
               fontweight='bold')
ax1.tick_params(axis='y', labelcolor=color_primary, labelsize=10)
ax1.set_xlim(0.5, 12.5)
ax1.grid(True, alpha=0.3, linestyle='--')

# Secondary axis styling
ax2 = ax1.twinx()
ax2.plot(months, conversion_rate, marker='s', color=color_secondary, 
         linewidth=2, label='Conversion Rate')
ax2.set_ylabel('Conversion Rate (%)', fontsize=12, color=color_secondary, 
               fontweight='bold')
ax2.tick_params(axis='y', labelcolor=color_secondary, labelsize=10)

# Format y-axis to show percentage
ax2.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.1f}%'))

plt.title('Website Performance Metrics', fontsize=14, fontweight='bold', 
          pad=20)
plt.tight_layout()
plt.show()

Notice how the line color, y-axis label color, and tick label color all match. This creates an immediate visual association. Readers can glance at the orange line, see orange numbers on the right axis, and understand the connection without thinking.

Handling Legends Properly

Legends become tricky with dual axes because each axis maintains its own legend entries. The solution is to manually combine them:

fig, ax1 = plt.subplots(figsize=(10, 6))

# Plot with labels
line1 = ax1.plot(months, page_views, marker='o', color='#2E86AB', 
                 linewidth=2, label='Page Views')
ax1.set_xlabel('Month', fontsize=12)
ax1.set_ylabel('Page Views', fontsize=12, color='#2E86AB')
ax1.tick_params(axis='y', labelcolor='#2E86AB')
ax1.grid(True, alpha=0.3, linestyle='--', zorder=0)

ax2 = ax1.twinx()
line2 = ax2.plot(months, conversion_rate, marker='s', color='#A23B72', 
                 linewidth=2, label='Conversion Rate')
ax2.set_ylabel('Conversion Rate (%)', fontsize=12, color='#A23B72')
ax2.tick_params(axis='y', labelcolor='#A23B72')

# Combine legends
lines = line1 + line2
labels = [l.get_label() for l in lines]
ax1.legend(lines, labels, loc='upper left', frameon=True, 
           shadow=True, fontsize=10)

plt.title('Website Performance Metrics', fontsize=14, pad=20)
plt.tight_layout()
plt.show()

The get_label() method extracts labels from line objects, allowing you to create a single unified legend. Place it on the primary axis (ax1) to ensure it appears properly.

Grid lines warrant consideration too. Generally, apply grids only to the primary axis to avoid visual clutter. If you need both, make the secondary grid more transparent.

Mixing Plot Types

Dual-axis plots shine when combining different visualization types. Bar charts with line overlays are particularly effective for showing composition alongside trends:

# Monthly revenue data
revenue = np.array([145, 152, 168, 155, 178, 185, 192, 188, 201, 215, 223, 238])
growth_rate = np.array([0, 4.8, 10.5, -7.7, 14.8, 3.9, 3.8, -2.1, 6.9, 7.0, 3.7, 6.7])

fig, ax1 = plt.subplots(figsize=(12, 6))

# Bar chart for revenue
bars = ax1.bar(months, revenue, color='#3A86FF', alpha=0.7, 
               label='Monthly Revenue', zorder=2)
ax1.set_xlabel('Month', fontsize=12, fontweight='bold')
ax1.set_ylabel('Revenue ($1000s)', fontsize=12, color='#3A86FF', 
               fontweight='bold')
ax1.tick_params(axis='y', labelcolor='#3A86FF')
ax1.set_xlim(0.5, 12.5)
ax1.grid(True, alpha=0.3, linestyle='--', axis='y', zorder=0)

# Line chart for growth rate
ax2 = ax1.twinx()
line = ax2.plot(months, growth_rate, marker='o', color='#FB5607', 
                linewidth=2.5, markersize=8, label='Growth Rate', zorder=3)
ax2.set_ylabel('Growth Rate (%)', fontsize=12, color='#FB5607', 
               fontweight='bold')
ax2.tick_params(axis='y', labelcolor='#FB5607')
ax2.axhline(y=0, color='#FB5607', linestyle=':', linewidth=1, alpha=0.5)

# Combined legend
lines_all = [bars] + line
labels_all = [l.get_label() for l in lines_all]
ax1.legend(lines_all, labels_all, loc='upper left', fontsize=10)

plt.title('Monthly Revenue and Growth Rate', fontsize=14, 
          fontweight='bold', pad=20)
plt.tight_layout()
plt.show()

The zorder parameter controls layering—higher values appear on top. Here, the line (zorder=3) appears above bars (zorder=2), which appear above the grid (zorder=0). This creates clear visual hierarchy.

Avoiding Common Mistakes

The biggest pitfall is scale manipulation. By adjusting the y-axis ranges independently, you can make any two variables appear correlated:

# Example of a misleading dual-axis plot
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# Misleading version - manipulated scales
months_sample = np.arange(1, 7)
metric_a = np.array([100, 102, 101, 103, 102, 104])
metric_b = np.array([50, 48, 51, 49, 52, 50])

ax_left = ax1
ax_left.plot(months_sample, metric_a, marker='o', color='blue', linewidth=2)
ax_left.set_ylabel('Metric A', color='blue')
ax_left.set_ylim(99, 105)  # Narrow range exaggerates changes
ax_left.tick_params(axis='y', labelcolor='blue')

ax_right = ax_left.twinx()
ax_right.plot(months_sample, metric_b, marker='s', color='red', linewidth=2)
ax_right.set_ylabel('Metric B', color='red')
ax_right.set_ylim(47, 53)  # Narrow range exaggerates changes
ax_right.tick_params(axis='y', labelcolor='red')
ax1.set_title('Misleading: Manipulated Scales Suggest False Correlation')

# Better alternative - separate subplots with same scale approach
ax2.plot(months_sample, (metric_a - metric_a.mean()) / metric_a.std(), 
         marker='o', label='Metric A (normalized)', linewidth=2)
ax2.plot(months_sample, (metric_b - metric_b.mean()) / metric_b.std(), 
         marker='s', label='Metric B (normalized)', linewidth=2)
ax2.set_ylabel('Normalized Value (std deviations)')
ax2.set_xlabel('Month')
ax2.legend()
ax2.grid(True, alpha=0.3)
ax2.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax2.set_title('Better: Normalized Values on Single Axis')

plt.tight_layout()
plt.show()

When in doubt, use subplots stacked vertically with aligned x-axes, or normalize both variables to a common scale. These alternatives are almost always clearer and more honest.

Additional best practices:

  • Never use dual-axis plots for unrelated variables just to save space
  • Always start y-axes at zero for bar charts, even on dual axes
  • Include units in axis labels (%, $, etc.)
  • Test your visualization by showing it to someone unfamiliar with the data—if they misinterpret it, redesign it
  • Consider colorblind-friendly palettes (avoid red-green combinations)

Dual-axis plots are powerful when used correctly, but they require restraint and careful design. Prioritize clarity over cleverness, and always ask whether a simpler visualization would serve your audience better.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.