Twin and Secondary Axes

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:

ax.set_ylabel("revenue", color=c1)
ax.tick_params(axis="y", labelcolor=c1)

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
Output

The legend problem

Each axes has its own artists, so a single legend needs assembling.

example_02.pymatplotlib
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.

Collect the handles and pass them to one call:

l1, = ax.plot(..., label="A")
l2, = ax2.plot(..., label="B")
ax.legend(handles=[l1, l2])

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
Output

The usual alternatives

Two panels, or one scale after normalising.

example_04.pymatplotlib
Output

secondary_axis is a different thing

One scale shown in two units, with a conversion between them.

example_05.pymatplotlib
Output

When a twin axis is reasonable

Related quantities, honestly labelled, where the shape is the point.

example_06.pymatplotlib
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:

ax.grid(True, axis="y", alpha=0.3)
ax2.grid(False)

Since ax2 takes from grid settings from rcParams, turning it off by name is needed when the style enables grids by default.

Aligning the two scales

A frequent request is to make zero on both axes fall at the same height. The two series are close around a baseline.

There's no built-in for it. It's math on the limits:

def align_zero(a1, a2):
    l1, h1 = a1.get_ylim(); l2, h2 = a2.get_ylim()
    f = l1 / (l1 - h1)
    a2.set_ylim(bottom=(f * h2) / (f - 1) if f != 1 else l2)

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:

ax3 = ax.twinx()
ax3.spines["right"].set_position(("outward", 45))

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.

  1. Why must you colour-code the two y axes on a twin plot?

  2. What determines where two twinned series cross?

  3. What is the honest alternative to a twin axis?

  4. How does `secondary_yaxis` differ from `twinx`?

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.

MATPLOTLIB · vizlearn.in/matplotlib/twin_axes.html

About the author

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.