2025, Oct 30 17:00
How to Align Matplotlib Secondary Axis Ticks One-to-One with the Primary Axis
Align Matplotlib secondary axis ticks with the primary axis using true forward/inverse functions and explicit tick control; avoid twinx/twiny for unit maps.
Secondary axes in Matplotlib look deceptively simple: you pass a pair of functions, and a new axis with transformed values appears. But once you want the ticks on the secondary axis to align one-to-one with the primary axis ticks, it is easy to get confused about what is being transformed and how to control it. The guide below clarifies the difference between Axes and axis, explains why twinx/twiny are not appropriate in this case, and shows how to force the secondary axis ticks to match the primary ones through explicit control.
Reproducing the setup
The example below uses a filled contour plot and attaches secondary axes intended to represent Y = 1/y and X = 3x. The goal is to align secondary ticks with primary ticks and compute their labels from a function of the primaries.
import matplotlib.pyplot as plt
import numpy as np
# function to visualize
def plane_fn(u, v):
    return (1 - (u ** 2 + v ** 3)) * np.exp(-(u ** 2 + v ** 2) / 2)
v_hi = 2.5
v_lo = 1
u_hi = v_hi
u_lo = v_lo
u_vals = np.arange(v_lo, v_hi, 0.01)
v_vals = np.arange(u_lo, u_hi, 0.01)
UU, VV = np.meshgrid(u_vals, v_vals)
WW = plane_fn(UU, VV)
fig1, ax1 = plt.subplots()
cf = ax1.contourf(UU, VV, WW)
x_ticks = np.linspace(u_lo, u_hi, 9)
x_tick_lbls = [fr'{i:.1f}' for i in x_ticks]
ax1.set_xticks(ticks=x_ticks, labels=x_tick_lbls)
ax1.set_xlabel('x')
y_ticks = np.linspace(v_lo, v_hi, 9)
y_tick_lbls = [fr'{i:.1f}' for i in y_ticks]
ax1.set_yticks(ticks=y_ticks, labels=y_tick_lbls)
ax1.set_ylabel('y')
cbar = fig1.colorbar(cf, ax=ax1, location='right')
cbar.ax.set_ylabel(r'$z=f(x,y)$')
_ = y_ticks ** 2  # placeholder calculation preserved from the setup
fig1.subplots_adjust(left=0.20)
def y_map(y):
    return 1 / y
# secondary y-axis, same function passed as its own inverse here
sec_y = ax1.secondary_yaxis('left', functions=(y_map, y_map))
sec_y.spines['left'].set_position(('outward', 40))
sec_y.set_ylabel(r'Y=1/y')
sec_y.yaxis.set_inverted(True)
fig1.subplots_adjust(bottom=0.27)
def x_map(x):
    return 3 * x
# secondary x-axis, incorrect inverse on purpose for the problem demonstration
sec_x = ax1.secondary_xaxis('bottom', functions=(x_map, x_map))
sec_x.spines['bottom'].set_position(('outward', 30))
sec_x.set_xlabel(r'X=3$\times$ x')
ax1.grid(visible=False)
ax1.set_title(r'$z=(1-x^2+y^3) e^{-(x^2+y^2)/2}$')
fig1.tight_layout()
plt.show()
What is the real issue?
The first subtlety is terminology. Matplotlib’s Axes (the object you plot on, like ax1) is different from an axis (the x/y scale with ticks and labels). A SecondaryAxis adds a transformed view of an existing axis: new ticks and labels computed through a pair of functions. It does not create a new Axes and does not provide a second place to plot data. By contrast, twinx/twiny create a brand-new Axes object overlaid on the original one, sharing a single direction (x for twinx, y for twiny) and maintaining an independent autoscaling in the other direction. That is useful for unrelated units, not for a deterministic mapping between the same quantity in two measures.
In short, twinx and twiny are orthogonal to the problem here. You do not want another Axes. You want the secondary axis ticks to reflect a conversion of the primary axis ticks, with visible alignment.
The second subtlety is control. By default, SecondaryAxis computes its own tick locations based on the provided transform functions and the current view limits. If you want exact one-to-one alignment, you should explicitly set secondary ticks by transforming the primary tick positions. Finally, when defining SecondaryAxis, Matplotlib expects a pair of reciprocal functions: forward and its true inverse. Passing a non-inverse may appear to work in some cases, but it is not guaranteed and can break or misbehave.
Solution: drive the secondary ticks yourself
The simplest way to get perfectly aligned ticks is to take the current primary ticks and map them through your forward function, then assign those as the secondary ticks. This guarantees spacing and positions correspond exactly on screen. Also make sure the function pair is actually reciprocal where required.
import matplotlib.pyplot as plt
import numpy as np
def plane_fn(u, v):
    return (1 - (u ** 2 + v ** 3)) * np.exp(-(u ** 2 + v ** 2) / 2)
v_hi = 2.5
v_lo = 1
u_hi = v_hi
u_lo = v_lo
u_vals = np.arange(v_lo, v_hi, 0.01)
v_vals = np.arange(u_lo, u_hi, 0.01)
UU, VV = np.meshgrid(u_vals, v_vals)
WW = plane_fn(UU, VV)
fig1, ax1 = plt.subplots()
cf = ax1.contourf(UU, VV, WW)
x_ticks = np.linspace(u_lo, u_hi, 9)
x_tick_lbls = [fr'{i:.1f}' for i in x_ticks]
ax1.set_xticks(ticks=x_ticks, labels=x_tick_lbls)
ax1.set_xlabel('x')
y_ticks = np.linspace(v_lo, v_hi, 9)
y_tick_lbls = [fr'{i:.1f}' for i in y_ticks]
ax1.set_yticks(ticks=y_ticks, labels=y_tick_lbls)
ax1.set_ylabel('y')
cbar = fig1.colorbar(cf, ax=ax1, location='right')
cbar.ax.set_ylabel(r'$z=f(x,y)$')
_ = y_ticks ** 2
fig1.subplots_adjust(left=0.20)
def y_map(y):
    return 1 / y
# y_map is its own inverse for positive y
sec_y = ax1.secondary_yaxis('left', functions=(y_map, y_map))
sec_y.spines['left'].set_position(('outward', 40))
sec_y.set_ylabel(r'Y=1/y')
sec_y.yaxis.set_inverted(True)
# align secondary y ticks with primary ticks via forward transform
sec_y.set_yticks(y_map(ax1.get_yticks()))
fig1.subplots_adjust(bottom=0.27)
def x_map(x):
    return 3 * x
# provide the true inverse for the x transform
sec_x = ax1.secondary_xaxis('bottom', functions=(x_map, lambda t: t / 3))
sec_x.spines['bottom'].set_position(('outward', 30))
sec_x.set_xlabel(r'X=3$\times$ x')
# align secondary x ticks with primary ticks via forward transform
sec_x.set_xticks(x_map(ax1.get_xticks()))
ax1.grid(visible=False)
ax1.set_title(r'$z=(1-x^2+y^3) e^{-(x^2+y^2)/2}$')
fig1.tight_layout()
plt.show()
This approach achieves linear spacing of secondary ticks in the transformed space and exact alignment with primary tick positions on the canvas.
Details that matter
Function pairs should be true inverses. For the y mapping, y → 1/y is its own inverse (on the positive domain). For the x mapping, x → 3x must be paired with t → t/3 as the inverse. While you might sometimes get a plausible result with a wrong inverse, that is not guaranteed behavior. The documentation expects a reciprocal pair, so deviating from that invites undefined behavior.
Labels can look “off” if you round them independently of their true tick positions. If you create ticks with linspace and then format labels to one decimal place, the label may not match the actual tick coordinate exactly, which becomes visible when you transform those values on the secondary axis. Whether you accept that rounding, use a different tick strategy, or format with more precision is your call; the effect is purely presentational but noticeable.
What about complicated transforms?
If the secondary axis values are computed by a complicated custom function and a closed-form inverse is difficult to specify, the expected contract of SecondaryAxis still requires providing an inverse. One practical route used in the wild is to follow the gallery example that leverages numpy interpolation; a reference solution applied that idea using np.interp from the fourth example in the Matplotlib secondary axis gallery. Separately, a natural question is what happens without an exact inverse; the documented interface states that the second function should be the inverse of the first.
Why this is worth understanding
Getting this right cleanly separates concerns. SecondaryAxis is for alternate views of the same underlying coordinate with a deterministic conversion, where ticks and labels are all you need. twinx/twiny are for plotting multiple datasets with different units or ranges on overlaid Axes that share a single direction. Using the right tool simplifies layout, avoids brittle autoscaling interactions, and keeps transformations explicit and predictable.
Summary and practical advice
If your goal is a secondary scale that is a function of the primary axis, stick with SecondaryAxis. Always supply a correct forward/inverse pair. To guarantee alignment, compute secondary ticks by transforming the primary tick locations, and set them explicitly on the secondary axis. Be mindful of label rounding, since formatted strings can diverge from the underlying tick coordinates and make transformed labels look inaccurate. For complex transforms where an analytic inverse is awkward, one workable approach seen in practice is to adapt the interpolation pattern demonstrated in Matplotlib’s secondary axis gallery.