ggplot2 Secondary Axis in R: Add a Second Y-Axis the Right Way
sec_axis() adds a secondary y-axis (or x-axis) to a ggplot2 plot by applying a mathematical transformation to the primary axis — it does not create an independent axis.
Introduction
You have temperature in Celsius on one axis and rainfall in millimetres on the other. Both belong on the same time-series plot, but their scales are completely different. How do you show both without creating a mess?
ggplot2 solves this with sec_axis(), a function that creates a secondary axis by transforming the primary axis's scale. The key insight is that ggplot2 does not support two truly independent y-axes. Instead, you rescale one variable to fit the primary axis, then label the secondary axis with the inverse transformation so readers see the original units.
In this tutorial, you will learn how sec_axis() works under the hood and how to compute the transformation coefficient from your data. You will also learn how to style dual-axis plots for readability — and when to avoid dual axes altogether. All code runs directly in your browser.
How does sec_axis() work?
The sec_axis() function lives inside scale_y_continuous() (or scale_x_continuous()). It takes a transformation formula and applies it to the primary axis tick values to produce the secondary axis labels.
The transformation must be strictly monotonic — every input maps to exactly one output with no reversals. Linear transformations like ~ . * 1.8 + 32 (Celsius to Fahrenheit) are the most common.
Let's start with the simplest case: a single variable plotted with a unit-conversion secondary axis.
library(ggplot2)
# Sample temperature data in Celsius
temp_data <- data.frame(
day = 1:10,
temp_c = c(18, 20, 22, 25, 28, 30, 27, 24, 21, 19)
)
p_basic <- ggplot(temp_data, aes(x = day, y = temp_c)) +
geom_line(linewidth = 1, color = "steelblue") +
geom_point(size = 2, color = "steelblue") +
scale_y_continuous(
name = "Temperature (°C)",
sec.axis = sec_axis(~ . * 1.8 + 32, name = "Temperature (°F)")
) +
labs(x = "Day") +
theme_minimal()
print(p_basic)
#> A line plot with Celsius on the left axis and Fahrenheit on the right axis
The left axis shows Celsius. The right axis shows the same data converted to Fahrenheit using the formula F = C * 1.8 + 32. Both axes represent the same variable in different units — this is the cleanest use case for sec_axis().
Key Insight
sec_axis() does not create an independent axis. It relabels the primary axis using a formula. The right-side tick marks are computed from the left-side tick marks, not from a separate variable.
Try it: Create a plot of distances in kilometres (use data.frame(trip = 1:5, km = c(10, 25, 42, 8, 55))) with a secondary axis that shows miles. The conversion is miles = km * 0.621.
# Try it: add a km-to-miles secondary axis
trip_data <- data.frame(trip = 1:5, km = c(10, 25, 42, 8, 55))
ex_p1 <- ggplot(trip_data, aes(x = trip, y = km)) +
geom_col(fill = "coral") +
scale_y_continuous(
name = "Distance (km)",
sec.axis = sec_axis(~ . * 0.621, name = "Distance (miles)")
)
# your code here — modify the sec_axis formula if needed
print(ex_p1)
#> Expected: bar chart with km on left, miles on right
Click to reveal solution
trip_data <- data.frame(trip = 1:5, km = c(10, 25, 42, 8, 55))
ex_p1 <- ggplot(trip_data, aes(x = trip, y = km)) +
geom_col(fill = "coral") +
scale_y_continuous(
name = "Distance (km)",
sec.axis = sec_axis(~ . * 0.621, name = "Distance (miles)")
) +
theme_minimal()
print(ex_p1)
#> Bar chart with km on the left axis and miles on the right axis
Explanation: The formula ~ . * 0.621 converts each km tick value to miles for the right axis.
How do you compute the transformation coefficient?
Unit conversions are straightforward because the formula is known. The harder case is plotting two different variables on the same chart — say, unemployment count and personal savings rate from R's built-in economics dataset.
The trick is a two-step process. First, rescale the second variable so it fits within the primary axis range. Second, use the inverse of that rescaling as the sec_axis() transformation so the right axis shows the original values.
Here is how to compute the linear transformation coefficients from the data.
The coefficient tells you how many units of the primary variable correspond to one unit of the secondary variable. The offset aligns the minimums. Now plot both variables, scaling psavert into the unemploy range.
The blue line reads off the left axis (unemployment). The red line reads off the right axis (savings rate). The sec_axis() formula is the inverse of the scaling: (. - offset) / coeff undoes the * coeff + offset applied to the data.
Tip
Always compute the scaling coefficient from your data's actual ranges. Guessing a round number like 1000 leads to one variable being squashed at the top or bottom of the plot.
Try it: Compute the coefficient and offset to plot mtcars$mpg (primary, left axis) against mtcars$hp (secondary, right axis). Print both values.
Explanation: The coefficient maps the hp range (52-335) onto the mpg range (10.4-33.9). The offset shifts the minimum of hp to align with the minimum of mpg.
How do you style a dual-axis plot so readers can tell which axis belongs to which line?
A dual-axis plot without colour-coding is a puzzle. Readers stare at two lines and two y-axes with no way to match them. The fix is to colour the axis titles, labels, and tick marks to match the corresponding line.
Use theme() to target the left and right axis elements separately.
p_styled <- ggplot(econ, aes(x = date)) +
geom_line(aes(y = unemploy), color = "steelblue", linewidth = 0.8) +
geom_line(aes(y = psavert * coeff + offset), color = "tomato", linewidth = 0.8) +
scale_y_continuous(
name = "Unemployed (thousands)",
sec.axis = sec_axis(~ (. - offset) / coeff, name = "Savings Rate (%)")
) +
labs(x = NULL, title = "US Unemployment vs Personal Savings Rate") +
theme_minimal() +
theme(
axis.title.y.left = element_text(color = "steelblue", size = 12),
axis.text.y.left = element_text(color = "steelblue"),
axis.title.y.right = element_text(color = "tomato", size = 12),
axis.text.y.right = element_text(color = "tomato")
)
print(p_styled)
#> Dual-axis plot with blue left axis (unemployment) and red right axis (savings)
Now each axis visually matches its line. The blue title "Unemployed (thousands)" pairs with the blue line; the red title "Savings Rate (%)" pairs with the red line. This is not optional decoration — it is a readability requirement.
Warning
Without colour-coding, readers cannot determine which line maps to which axis. A dual-axis plot missing this step is worse than two separate plots because it actively confuses.
Try it: Modify the styled plot above so the right axis uses "darkgreen" instead of "tomato". Change the corresponding geom_line() colour to match.
# Try it: change right axis to darkgreen
ex_styled <- ggplot(econ, aes(x = date)) +
geom_line(aes(y = unemploy), color = "steelblue", linewidth = 0.8) +
geom_line(aes(y = psavert * coeff + offset), color = "darkgreen", linewidth = 0.8) +
scale_y_continuous(
name = "Unemployed (thousands)",
sec.axis = sec_axis(~ (. - offset) / coeff, name = "Savings Rate (%)")
) +
theme_minimal() +
theme(
axis.title.y.left = element_text(color = "steelblue"),
axis.text.y.left = element_text(color = "steelblue"),
# your code here — style the right axis darkgreen
axis.title.y.right = element_text(color = "darkgreen"),
axis.text.y.right = element_text(color = "darkgreen")
)
print(ex_styled)
#> Expected: right axis and line both in dark green
Click to reveal solution
ex_styled <- ggplot(econ, aes(x = date)) +
geom_line(aes(y = unemploy), color = "steelblue", linewidth = 0.8) +
geom_line(aes(y = psavert * coeff + offset), color = "darkgreen", linewidth = 0.8) +
scale_y_continuous(
name = "Unemployed (thousands)",
sec.axis = sec_axis(~ (. - offset) / coeff, name = "Savings Rate (%)")
) +
theme_minimal() +
theme(
axis.title.y.left = element_text(color = "steelblue"),
axis.text.y.left = element_text(color = "steelblue"),
axis.title.y.right = element_text(color = "darkgreen"),
axis.text.y.right = element_text(color = "darkgreen")
)
print(ex_styled)
#> Dual-axis plot with green right axis matching the green savings-rate line
Explanation: Both axis.title.y.right and axis.text.y.right must be set to the same colour as the geom_line() for the secondary variable.
When should you use a secondary axis (and when should you avoid it)?
Dual-axis plots have a bad reputation in the data visualization community, and much of that reputation is earned. The problem is that the visual relationship between two lines depends entirely on the scaling factor you choose — and that choice is arbitrary.
Watch what happens when we change the coefficient. The exact same data tells a completely different story.
# Misleading example: change the coefficient
# Tight scaling — lines appear to move together
coeff_tight <- 500
offset_tight <- min(econ$unemploy) - coeff_tight * min(econ$psavert)
p_mislead <- ggplot(econ, aes(x = date)) +
geom_line(aes(y = unemploy), color = "steelblue", linewidth = 0.8) +
geom_line(aes(y = psavert * coeff_tight + offset_tight),
color = "tomato", linewidth = 0.8) +
scale_y_continuous(
name = "Unemployed (thousands)",
sec.axis = sec_axis(~ (. - offset_tight) / coeff_tight,
name = "Savings Rate (%)")
) +
labs(title = "Same data, different coefficient — looks like they move together") +
theme_minimal()
print(p_mislead)
#> The savings-rate line appears flattened, visually "tracking" unemployment
By shrinking the coefficient, the savings-rate line looks nearly flat and appears to follow unemployment. A different coefficient could make them look inversely related. The data has not changed — only the visual framing has.
When dual axes are appropriate:
Unit conversions on the same variable (Celsius/Fahrenheit, km/miles, kg/lbs). There is no ambiguity because both axes represent the same thing.
Pareto charts combining counts with cumulative percentages. The two axes have a known mathematical relationship.
When dual axes mislead:
Two unrelated variables (GDP and ice cream sales). The visual correlation is an artifact of scaling.
Variables with different causal structures. Even if correlated, overlaying them implies a relationship that may not exist.
Better alternatives: Use facet_wrap() to give each variable its own panel, or use the patchwork package to stack separate plots.
Warning
Changing the scaling factor can make the same data look correlated, uncorrelated, or inversely related. If the two variables are not unit conversions of each other, consider facets instead.
Try it: Create a faceted version of the unemployment/savings plot using tidyr::pivot_longer() and facet_wrap(). This avoids the dual-axis problem entirely.
# Try it: faceted alternative
econ_long <- data.frame(
date = rep(econ$date, 2),
variable = rep(c("Unemployed (thousands)", "Savings Rate (%)"),
each = nrow(econ)),
value = c(econ$unemploy, econ$psavert)
)
ex_facet <- ggplot(econ_long, aes(x = date, y = value)) +
geom_line(color = "steelblue", linewidth = 0.7) +
facet_wrap(~ variable, scales = "free_y", ncol = 1) +
# your code here — add theme_minimal() and a title
theme_minimal()
print(ex_facet)
#> Expected: two panels stacked vertically, each with its own y-axis
Click to reveal solution
econ_long <- data.frame(
date = rep(econ$date, 2),
variable = rep(c("Unemployed (thousands)", "Savings Rate (%)"),
each = nrow(econ)),
value = c(econ$unemploy, econ$psavert)
)
ex_facet <- ggplot(econ_long, aes(x = date, y = value)) +
geom_line(color = "steelblue", linewidth = 0.7) +
facet_wrap(~ variable, scales = "free_y", ncol = 1) +
labs(title = "Faceted Alternative to Dual Axes", x = NULL) +
theme_minimal()
print(ex_facet)
#> Two stacked panels: unemployment on top, savings rate on bottom
Explanation:facet_wrap(scales = "free_y") gives each variable its own y-axis without any arbitrary scaling.
What are dup_axis() and secondary x-axes?
Beyond sec_axis(), ggplot2 provides dup_axis() — a shorthand that mirrors the primary axis on the opposite side without any transformation.
# dup_axis: mirror the y-axis on the right
p_dup <- ggplot(temp_data, aes(x = day, y = temp_c)) +
geom_line(linewidth = 1, color = "steelblue") +
scale_y_continuous(
name = "Temperature (°C)",
sec.axis = dup_axis(name = "Temperature (°C)")
) +
theme_minimal()
print(p_dup)
#> Line plot with identical y-axes on both left and right sides
This is useful for wide plots or presentations where the audience sits far from the screen. Having tick marks on both sides lets readers reference the nearest axis.
You can also add a secondary x-axis. The syntax is identical, but inside scale_x_continuous().
# Secondary x-axis: show row index on bottom, scaled value on top
p_secx <- ggplot(temp_data, aes(x = day, y = temp_c)) +
geom_line(linewidth = 1, color = "steelblue") +
scale_x_continuous(
name = "Day Number",
sec.axis = sec_axis(~ . + 10, name = "Day of Month (offset by 10)")
) +
theme_minimal()
print(p_secx)
#> Line plot with "Day Number" on the bottom x-axis and offset labels on top
The top x-axis shows each day number plus 10. In practice, secondary x-axes are less common but handy for showing a date range alongside an index, or experiment day alongside a calendar date.
Tip
Use dup_axis() when you want the same scale on both sides. It saves the audience from tracking their eyes across a wide chart to read the axis.
Try it: Add a top x-axis that mirrors the bottom x-axis using dup_axis() inside scale_x_continuous().
# Try it: mirrored top x-axis
ex_dup_x <- ggplot(temp_data, aes(x = day, y = temp_c)) +
geom_line(linewidth = 1, color = "steelblue") +
scale_x_continuous(
name = "Day",
sec.axis = dup_axis(name = "Day")
)
# your code here — add theme_minimal() and print
print(ex_dup_x)
#> Expected: line plot with day labels on both bottom and top x-axes
Click to reveal solution
ex_dup_x <- ggplot(temp_data, aes(x = day, y = temp_c)) +
geom_line(linewidth = 1, color = "steelblue") +
scale_x_continuous(
name = "Day",
sec.axis = dup_axis(name = "Day")
) +
theme_minimal()
print(ex_dup_x)
#> Line plot with mirrored x-axes on bottom and top
Explanation:dup_axis() inside scale_x_continuous() mirrors the bottom axis to the top, using the same breaks and labels.
Common Mistakes and How to Fix Them
Mistake 1: Plotting the second variable without rescaling it
This is the most common error. You add a second geom_line() with the raw secondary variable, but both lines share the primary axis scale. One variable dominates the plot or is invisible.
❌ Wrong:
# psavert ranges 2-8, unemploy ranges 6000-15000
# psavert line is invisible at the bottom of the plot
p_wrong1 <- ggplot(econ, aes(x = date)) +
geom_line(aes(y = unemploy), color = "steelblue") +
geom_line(aes(y = psavert), color = "tomato") +
scale_y_continuous(
sec.axis = sec_axis(~ . , name = "Savings Rate (%)")
) +
theme_minimal()
print(p_wrong1)
#> The red line (psavert) is squashed flat at y = 0
Why it is wrong:psavert values (2-8) are plotted on the same scale as unemploy (6000-15000). The red line hugs zero.
✅ Correct:
# Rescale psavert into the unemploy range
p_fix1 <- ggplot(econ, aes(x = date)) +
geom_line(aes(y = unemploy), color = "steelblue") +
geom_line(aes(y = psavert * coeff + offset), color = "tomato") +
scale_y_continuous(
name = "Unemployed (thousands)",
sec.axis = sec_axis(~ (. - offset) / coeff, name = "Savings Rate (%)")
) +
theme_minimal()
print(p_fix1)
#> Both lines use the full vertical space of the plot
Mistake 2: Using a non-monotonic transformation
sec_axis() requires a strictly monotonic function. A parabola (~ .^2) or sine wave is not monotonic across its full range and will produce incorrect or erroring output.
❌ Wrong:
# This may produce warnings or incorrect axis labels
# sec_axis(~ sin(.), name = "Sine") — non-monotonic!
Why it is wrong:sin() is periodic — the same output maps to multiple inputs. ggplot2 cannot invert the transformation to produce correct labels.
✅ Correct: Stick to linear transformations (~ . * a + b) or strictly monotonic functions like log(), sqrt(), or exp().
Mistake 3: Mismatch between data scaling and axis transformation
The formula inside sec_axis() must be the exact inverse of the transformation applied to the data. If you scale the data by * 1000 but set the axis to ~ . / 500, the right-axis labels will be wrong.
❌ Wrong:
# Data scaled by coeff + offset, but axis uses different values
p_mismatch <- ggplot(econ, aes(x = date)) +
geom_line(aes(y = psavert * 1000 + 5000), color = "tomato") +
scale_y_continuous(
sec.axis = sec_axis(~ (. - 3000) / 800, name = "Savings Rate (%)")
) +
theme_minimal()
print(p_mismatch)
#> Right axis labels do NOT match the actual psavert values
Why it is wrong: The data uses * 1000 + 5000 but the axis inverse uses - 3000 / 800. The numbers on the right axis are meaningless.
✅ Correct: Always use the exact inverse. If data = y * a + b, then axis = ~ (. - b) / a.
Practice Exercises
Exercise 1: Temperature conversion with airquality
Plot the daily temperature from R's airquality dataset (May through September) with Fahrenheit on the left axis and Celsius on the right axis. Colour-code both axes. Add a title.
# Exercise 1: airquality temperature with F and C axes
# Hint: Celsius = (Fahrenheit - 32) / 1.8
# Use sec_axis() and theme() for axis colours
# Write your code below:
Click to reveal solution
aq <- airquality
aq$day_index <- seq_len(nrow(aq))
ex1_p <- ggplot(aq, aes(x = day_index, y = Temp)) +
geom_line(color = "#E74C3C", linewidth = 0.7) +
scale_y_continuous(
name = "Temperature (°F)",
sec.axis = sec_axis(~ (. - 32) / 1.8, name = "Temperature (°C)")
) +
labs(x = "Day (May-Sep)", title = "NYC Daily Temperature — Dual Unit Axes") +
theme_minimal() +
theme(
axis.title.y.left = element_text(color = "#E74C3C"),
axis.text.y.left = element_text(color = "#E74C3C"),
axis.title.y.right = element_text(color = "#2E86C1"),
axis.text.y.right = element_text(color = "#2E86C1")
)
print(ex1_p)
#> Line chart: red left axis (F), blue right axis (C)
Explanation: Since airquality$Temp is already in Fahrenheit, the sec_axis() transformation converts to Celsius using the standard formula.
Exercise 2: Pareto chart from mtcars cylinders
Create a Pareto chart showing the count of cars by cylinder number (bar chart, left axis) and cumulative percentage (line, right axis). Sort bars from most to least frequent.
# Exercise 2: Pareto chart
# Hint: compute cumulative percentage, scale it into the count range
# Use geom_col() for bars and geom_line() + geom_point() for cumulative %
# Write your code below:
Explanation: The cumulative percentage is rescaled into the count range (cum_pct / 100 * max_count). The sec_axis() reverses this (~ . / max_count * 100) so the right axis shows 0-100%.
Putting It All Together
Here is a complete, polished dual-axis plot that combines everything from this tutorial: computed coefficients, colour-coded axes, clear labels, and a professional theme.
This plot uses scales::comma for readable left-axis labels, date breaks for a clean x-axis, and bold colour-coded titles. A reader can immediately tell which line belongs to which axis.
Summary
Function
Purpose
Best use case
sec_axis(~ formula)
Transform primary axis to create a secondary axis
Unit conversions (C/F, km/mi), Pareto charts
dup_axis()
Mirror primary axis on the opposite side
Wide plots, presentations
Coefficient formula
a = range(y1) / range(y2), b = min(y1) - a * min(y2)
Aligning two variables with different scales
Axis styling
theme(axis.title.y.right = element_text(...))
Colour-coding axes to match lines
Avoid dual axes
Use facet_wrap(scales = "free_y") instead
Unrelated variables on different scales
Key takeaways:
sec_axis() transforms the primary axis — it does not create an independent second axis
The transformation must be strictly monotonic
Always compute the coefficient from your data ranges, never guess
Colour-code axes to match their lines — this is not optional
Dual axes are best for unit conversions; for unrelated variables, prefer facets
FAQ
Can sec_axis() create a truly independent second y-axis?
No. ggplot2's design philosophy is that one axis, one scale, one mapping. The secondary axis must be a mathematical function of the primary axis. If you need two completely independent y-axes, use facet_wrap(scales = "free_y") or the patchwork package to combine two separate plots.
Does sec_axis() work with date or datetime axes?
It does, but with restrictions. Datetime axis transformations are limited to addition and subtraction (e.g., shifting time zones with ~ . + 3600). Nonlinear transformations like log() or multiplication will produce errors because they violate the POSIX structure.
How do I add a secondary axis to a bar chart?
The same way as with lines. Use geom_col() for the primary variable and overlay a geom_line() or geom_point() for the secondary variable (rescaled). The sec_axis() call stays inside scale_y_continuous().
Can I have both a secondary x-axis and a secondary y-axis on the same plot?
Yes. Use sec.axis inside both scale_x_continuous() and scale_y_continuous(). Each axis gets its own transformation formula.
References
ggplot2 documentation — sec_axis() reference. Link
R Graph Gallery — Dual Y axis with R and ggplot2. Link
Wickham, H. — ggplot2: Elegant Graphics for Data Analysis, 3rd Edition. Springer (2024). Chapter 10: Scales. Link
Healy, K. — Data Visualization: A Practical Introduction. Princeton University Press (2018). Section on dual axes. Link
Few, S. — "Dual-Scaled Axes in Graphs: Are They Ever the Best Solution?" Visual Business Intelligence Newsletter (2008). Link