Two scales on one plot - how, and the reasons to think twice.
Overview
twinx and twiny
A dual-axis chart draws two datasets on the same figure with different y scales. Revenue on the left, growth rate on the right. Temperature on the left, humidity on the right. The shared x axis aligns them in time while the separate y axes accommodate different units and ranges.
Dual axes are controversial. They make it easy to imply a correlation that does not exist. Two unrelated series can be made to track each other by adjusting the y scales. The reader sees two lines moving together and infers a relationship that is an artifact of the axis limits. This is why many data-visualisation guides advise against them.
That said, dual axes have legitimate uses. When two series share a natural alignment -- the same time period, the same set of categories -- and the audience understands that the two scales are independent, a dual-axis chart saves space compared to two separate charts. The key is that the reader must know to look at the scale before interpreting the pattern.
This page covers twinx and twiny, the colour-coding that makes dual axes readable, the legend problem that arises when two axes objects need a single legend, the alignment issues that come up when tick marks do not match, and the alternatives that avoid the dual-axis trap entirely.
Small multiples are the most common alternative. Two panels, stacked vertically, sharing the x axis. Each panel has its own y axis and its own label. The reader compares the shapes without the risk of misreading the scales. The page shows how to build both and when each is appropriate.
If you do use dual axes, colour-coding is mandatory. Each y-axis label and its tick labels must match the colour of the line that uses that scale. Without colour-coding, the reader has two scales and no way to tell which line belongs to which. The chart becomes unreadable.
The legend is also tricky. ax.legend() shows only the artists on ax. ax2.legend() shows only the artists on ax2. Getting a single legend with entries from both requires collecting the handles and labels from both axes and passing them to one legend call. The page shows the pattern.
ax2 = ax.twinx() creates a second axes occupying the same space, sharing the x axis and having its own y axis on the right. twiny() is the transpose.
all drawn on ax2 uses the right-hand scale. They're truly split axes objects: split limits, split ticks, split artists.
The one styling step that isn't optional is colour-coding. Set each y label and its tick labels to match the colour of the series that uses it:
Without it, the reader has two scales and no way to tell which belongs to which line. The chart is simply unreadable.
Worth knowing
ax.twinx() returns a new axes sharing the x axis; colour each y label and its ticks to match its series.
Each axes owns its artists, so one legend needs the handles collected and passed explicitly.
The point where two twinned series cross is an artefact of the limits you chose, and readers read it as a fact.
The honest alternatives are stacked panels with a shared x, or indexing both series to a common base and using one axis.
secondary_yaxis(functions=(fwd, inv)) relabels the same data in another unit — no arbitrary scale, nothing to mislead.
A twin axis is defensible when one series is clearly background context — volume bars behind a price line.
Twin and Secondary Axes
Two scales on one plot, and the reasons to think twice.
twinx shares the x axis
A second axes on top of the first, with its own y scale.
example_01.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
months = np.arange(1, 13)
revenue = np.array([120, 135, 128, 150, 175, 190, 185, 200, 178, 160, 145, 165])
rate = np.array([2.1, 2.3, 2.0, 2.6, 3.1, 3.4, 3.2, 3.6, 3.0, 2.7, 2.4, 2.9])
fig, ax = plt.subplots(figsize=(7, 3.5))
ax.plot(months, revenue, color="#264653", marker="o", label="revenue")
ax.set_ylabel("revenue (thousands)", color="#264653")
ax.tick_params(axis="y", labelcolor="#264653")
ax2 = ax.twinx()
ax2.plot(months, rate, color="#e76f51", marker="s", linestyle="--", label="rate %")
ax2.set_ylabel("conversion rate (%)", color="#e76f51")
ax2.tick_params(axis="y", labelcolor="#e76f51")
print("twinx() returns a NEW axes sharing the x axis.")
print("Colouring each y label and its ticks to match its line is what")
print("makes the chart readable - without it, nobody knows which")
print("scale belongs to which series.")
Output
The legend problem
Each axes has its own artists, so a single legend needs assembling.
example_02.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.arange(12)
a_vals = np.linspace(100, 200, 12)
b_vals = np.linspace(3, 1, 12)
fig, (p, q) = plt.subplots(1, 2, figsize=(10, 3.2))
p2 = p.twinx()
p.plot(x, a_vals, color="#264653", label="A")
p2.plot(x, b_vals, color="#e76f51", label="B")
p.legend(loc="upper left")
p2.legend(loc="upper right")
p.set_title("two legends - clumsy")
q2 = q.twinx()
l1, = q.plot(x, a_vals, color="#264653", label="A")
l2, = q2.plot(x, b_vals, color="#e76f51", label="B")
q.legend(handles=[l1, l2], loc="upper center")
q.set_title("one legend, handles collected")
print("ax.legend() only sees artists on that axes.")
print("Collect the handles and pass them explicitly for one legend.")
Output
ax.legend() collects labelled artists on that axes only. With a twin, that means each half of the chart gets its own legend. This Looks like a mistake.
Or gather them generically with ax.get_legend_handles_labels() on each axes and concatenate.
Why twin axes mislead
The crossing point is an artefact of the two scales you chose.
example_03.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.arange(10)
a_vals = np.linspace(10, 90, 10)
b_vals = np.linspace(80, 20, 10)
fig, axes = plt.subplots(1, 3, figsize=(10, 3))
for ax, (lo, hi) in zip(axes, [(0, 100), (0, 200), (-50, 100)]):
ax.plot(x, a_vals, color="#264653")
a2 = ax.twinx()
a2.plot(x, b_vals, color="#e76f51", linestyle="--")
a2.set_ylim(lo, hi)
ax.set_title("right axis %s" % str((lo, hi)), fontsize=9)
print("The same two series, three choices of right-hand limits.")
print("The lines cross in a different place each time - and in the")
print("third they barely meet at all.")
print()
print("A reader sees 'they crossed in June' as a fact about the data.")
print("It is a fact about the axis limits, which you chose.")
Output
The usual alternatives
Two panels, or one scale after normalising.
example_04.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
months = np.arange(1, 13)
revenue = np.array([120, 135, 128, 150, 175, 190, 185, 200, 178, 160, 145, 165],
dtype=float)
rate = np.array([2.1, 2.3, 2.0, 2.6, 3.1, 3.4, 3.2, 3.6, 3.0, 2.7, 2.4, 2.9])
fig, (a, b) = plt.subplots(2, 1, figsize=(6.5, 4), sharex=True)
a.plot(months, revenue, color="#264653", marker="o")
a.set_ylabel("revenue")
b.plot(months, rate, color="#e76f51", marker="s")
b.set_ylabel("rate %")
fig.suptitle("stacked panels, shared x")
fig2, ax = plt.subplots(figsize=(6.5, 3))
ax.plot(months, revenue / revenue[0] * 100, color="#264653", marker="o",
label="revenue")
ax.plot(months, rate / rate[0] * 100, color="#e76f51", marker="s",
label="rate")
ax.set_ylabel("indexed to January = 100")
ax.legend()
ax2 = None
print("Stacked panels with a shared x compare shapes without inventing")
print("a crossing point.")
print("Indexing both to a common base puts them on ONE axis honestly -")
print("now the crossing means something: relative growth diverged.")
Output
secondary_axis is a different thing
One scale shown in two units, with a conversion between them.
example_05.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
days = np.arange(0, 60)
temp_c = 18 + 8 * np.sin(days / 12)
fig, ax = plt.subplots(figsize=(7, 3.2))
ax.plot(days, temp_c, color="#2a9d8f")
ax.set_ylabel("temperature (C)")
ax.set_xlabel("day")
sec = ax.secondary_yaxis("right",
functions=(lambda c: c * 9 / 5 + 32,
lambda f: (f - 32) * 5 / 9))
sec.set_ylabel("temperature (F)")
print("secondary_yaxis takes a pair of functions: forward and inverse.")
print()
print("This is NOT a second data series. It is the same data relabelled,")
print("so there is no arbitrary scale choice and nothing to mislead.")
print("Celsius/Fahrenheit, mm/inches, log/linear - all safe.")
Output
When a twin axis is reasonable
Related quantities, honestly labelled, where the shape is the point.
example_06.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.arange(24)
rng = np.random.default_rng(0)
volume = rng.integers(200, 900, 24)
price = 100 + np.cumsum(rng.normal(0, 1.5, 24))
fig, ax = plt.subplots(figsize=(7.5, 3.5))
ax.bar(x, volume, color="0.85", label="volume")
ax.set_ylabel("volume")
ax.set_ylim(0, volume.max() * 3) # push the bars down
ax2 = ax.twinx()
ax2.plot(x, price, color="#264653", linewidth=2, label="price")
ax2.set_ylabel("price")
print("Price and volume on one chart is the classic defensible case:")
print("bars in the background as context, a line in front as the")
print("subject. The eye does not try to compare them - one is clearly")
print("secondary.")
print()
print("Setting the bar axis limit high pushes the bars to the bottom,")
print("which reinforces that they are background.")
Output
Why they mislead
This is the part worth taking seriously.
Two series on split scales have no defined link. Where they cross. This Is above which, whether they diverge. All of it depends on the limits you chose for the second axis. Change ax2.set_ylim and the crossing moves, or disappears.
The third editor draws the same two series three times with other right-hand limits. They cross in three other places.
Readers don't see it that way. A crossing looks like an event: "revenue overtook cost in June". It's an artefact of a choice you made, often without thinking about it. Matplotlib picked the limits on its own.
This is the method behind a large share of misleading charts in the wild. Most of them aren't deliberate.
The alternatives
Stacked panels with sharex=True. Two small charts, one above the other, sharing the time axis. The shapes can be checked, the levels can't be confused. No crossing is implied. This is the right answer most of the time.
Index both to a common base. Divide each series by its first value and times by 100. Both are then percentages of their own starting point, they share one axis honestly. A crossing now means a thing real: scaled growth diverged.
Percentage change does the same job for series where the level isn't real.
Two charts side by side, when the series have nothing in common but the time axis.
secondary_axis
ax.secondary_yaxis("right", functions=(forward, inverse)) is a other method and doesn't share the drawbacks.
It shows the same data in a second unit, with an clear conversion. Celsius and Fahrenheit, millimetres and inches, a count and a percentage of a known total.
Because the two scales are related by a function rather than by an any choice, nothing about the chart can mislead. The line is in one place. The two axes are two ways of reading the same spot.
The functions must be inverses of each other. Matplotlib uses both. Forward to place the ticks, inverse to keep them steady when the view changes.
secondary_xaxis does the same flatly. This Is how you put dates on one side and elapsed days on the other.
When a twin axis is fine
There's a defensible case, and it's narrower than its popularity suggests.
It works when one series is clearly background context rather than a co-equal subject. The standard example is price and volume. Volume as pale bars pushed to the bottom of the chart, price as a line in front. No one tries to compare a bar height with a line spot. The visual tree makes clear which one is the subject.
Setting the bar axis limit to two or three times the data range pushes the bars down and reinforces that.
The test: if a reader might compare the two series against each other, the twin axis is a hazard. If one is obviously scenery, it's a fair use of space.
Ordering and z-order
The twin axes is created after the first. Its artists draw on top by default.
That is often wrong when the first series is the subject. A background bar series drawn on ax ends up behind. This Is right. A line drawn on ax is covered by anything on ax2.
ax.set_zorder(ax2.get_zorder() + 1) moves the first axes in front. ax.patch.set_visible(False) is then needed, or its opaque background hides the second axes fully.
That two-line incantation appears in a great deal of twin-axis code. It exists because the axes are truly stacked rather than merged.
Grid lines on a twin
Both axes can draw a grid. Two grids at other spacings is visual noise that makes both harder to read.
The rule is to grid only the axes whose scale the reader should use for judging values. Often the main one. And to turn the other off:
Whether that is a good idea is another question. Aligning zero makes the chart look more close without making it more close. The scales are still unrelated. The reader is now more likely to compare them.
Three axes
ax.twinx() twice gives a third scale, with the third spine pushed outward:
It works, and it's almost always a mistake. Three unrelated scales on one plot is three any choices, and no amount of colour coding makes the crossings real.
If three series must be shown together, three stacked panels sharing an x axis is the display that does it honestly, and it isn't much taller.
The honest summary
Twin axes exist because people want them, and matplotlib gives them without editorialising.
They're right when one series is context and the other is the subject, when the two quantities are related by a thing the reader knows, or when a rule in the field makes the pairing familiar.
They're inappropriate when the chart invites a compare of levels or a reading of the crossing point. Which is most of the time. Is why the options in this module are worth reaching for first.
How the misreading happens
Worth spelling out, because it isn't obvious that anything is wrong.
A reader sees two lines. Lines on a chart most times share a scale. Distance between them means a thing. Here it doesn't. The vertical gap is the gap between two any rescalings.
The crossing is worse. A crossing is a visually salient event. The reader reads it as "the quantities became equal". Which is meaningless when the units differ. Move one axis's limits and the crossing moves, or vanishes.
None of that is signalled. The chart looks like every other two-line chart. The two y axes are the only clue that the usual reading doesn't apply.
If you must
When a twin axis is truly the right choice, four things make it as safe as it can be.
Colour-code both axes to their series, including the tick labels.
Make the tree obvious — one series as background context, the other as the subject.
Don't let them cross, if the limits can fairly be set to avoid it.
Say in the caption that the scales are split.
And check the option honestly first. Most twin-axis charts exist because two series were to hand, not because the compare needed one plot.
A decision procedure
When a second scale is proposed, four questions settle it.
Are the units related by a known conversion? If yes, secondary_yaxis with the conversion functions. Safe. Not really a second series at all.
Is one series clearly background? If yes, a twin axis is defensible, with the background pushed down and drawn in a pale colour.
Would indexing both to a common base answer the question? often yes. It puts them on one honest axis where crossings mean a thing.
Would two stacked panels do? Almost always yes, and it costs nothing but vertical space.
Only if the first two are no and the second two are truly unsuitable is a co-equal twin axis the right answer. And by that point the thinking has been made clear. This Is the handy part of the exercise.
Twin and Secondary Axes, in summary
twinx gives a second axes sharing the x axis and its own y scale.
Colour-code both axes to their series, or the chart is unreadable.
One legend needs collecting the handles from both axes.
The crossing point is found by the limits you chose. Readers treat it as a finding. Which is the whole problem.
secondary_yaxis with a function pair is a other and safe method, relabelling one series in another unit.
The honest options are stacked panels sharing an x axis, or indexing both series to a common base.
And a twin axis is fair when one series is obviously scenery. Volume behind price. Because then no one is trying to compare them.
A worked alternative
Given revenue in thousands and conversion rate in percent over twelve months, the twin-axis version puts them on one plot and invites a compare that isn't defined.
Three options, each answering a slightly other question.
Stacked panels, sharing the x axis. Answers "how did each move?" and allows the shapes to be checked without implying anything about levels.
Indexed to January = 100. Answers "which grew faster?" and puts both on one honest axis. Here A crossing truly means scaled speed diverged.
A scatter of one against the other, coloured or labelled by month. Answers "are they related?" right. This Is often the underlying question when someone reaches for a twin axis.
That third one is worth considering more often. A twin-axis chart is often an attempt to show a link using a display designed for showing two time series. A scatter shows the link.
One more thing
ax2.set_ylim should often be set by name on a twin axis rather than left auto.
The reason is reproducibility rather than aesthetics: auto limits depend on the data. A chart regenerated next month has a other second scale and the two lines cross one spot new. If a reader has seen the earlier version, that looks like a change in the data.
Twin and Secondary Axes: the short version
A second y axis is the only common chart feature whose main effect is on what the reader wrongly concludes.
all else in this track can be done badly. A twin axis is hard to do well even on purpose. The ambiguity is in the display rather than in the run. Reaching for stacked panels or a common index first is the habit worth having.
Questions people ask about Twin and Secondary Axes
How do I put two y axes on one chart? Call ax2 = ax.twinx(). It shares the x axis and has its own y axis on the right.
Why are twin axes controversial? The two scales are chosen freely, so the lines can appear to correlate or cross anywhere. Readers infer a relationship that is only a result of the scaling.
What can I use instead? Two stacked panels with a shared x axis, or indexing both series to a base of 100 on one axis.
How do I get one legend for both axes? Collect handles and labels from each axes with get_legend_handles_labels and pass the combined lists to one legend call.
What does secondary_axis do? It adds an axis that is a function of the first, such as Celsius and Fahrenheit. No new data is plotted.
How do I tell readers which axis belongs to which line? Colour each axis label and tick labels to match its line.
Reading the code back
A twin axis is one call and a series of mitigations. Colour-code both scales, assemble one legend, fix the limits by name, establish a visual tree. Say in the caption that the scales are split. The length of that list is itself the arg for checking whether stacked panels would do instead.
Check yourself
0 of 4
Answer without scrolling back up.
Why must you colour-code the two y axes on a twin plot?
Set both the label colour and tick_params labelcolor. Without it the chart is simply unreadable.
What determines where two twinned series cross?
Readers see a crossing as an event in the data. It is an artefact of a choice usually made automatically.
What is the honest alternative to a twin axis?
Indexing puts them on one axis honestly, and then a crossing means something real - relative growth diverged.
How does `secondary_yaxis` differ from `twinx`?
Celsius/Fahrenheit or mm/inches - the two scales are related by a function, so nothing can mislead.
Cheat sheet
Twin and Secondary Axes
Add a second y-axis to a matplotlib plot with twinx. See why dual axes can mislead readers and learn the safer alternatives for two scales.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.