Limits, Ticks and Scales

Controlling what the axis shows and how it is written - and the log scale that makes a curve readable.

Overview

Ticks

ax.set_xticks(positions) decides where the ticks go. ax.set_xticklabels(labels) decides what they say.

Setting labels without setting positions is the classic bug. matplotlib chooses its own positions based on the current view, and set_xticklabels simply renames whatever ticks happen to exist. Change the data and the labels stay put, now attached to different values. The symptom is a chart where the labels are subtly wrong and nothing errored.

Always set positions first, or pass both together: ax.set_xticks(pos, labels).

For automatic but controlled ticks, matplotlib.ticker has locators: MaxNLocator(5) for at most five ticks, MultipleLocator(0.25) for every quarter, LogLocator for log axes.

ax.tick_params(axis="x", rotation=45, labelsize=9) handles rotation and size, which is the usual fix for crowded category labels — though barh is usually the better fix.

Worth knowing

matplotlib adds a margin beyond the data; ax.margins(0) or explicit limits remove it. Reversing an axis is giving the limits backwards.
set_xticks sets positions and set_xticklabels the text — setting labels without positions puts them on the wrong values.
A formatter changes how numbers are written. Large values otherwise get a 1e6 offset in the corner that readers miss.
set_yscale("log") turns exponential growth into a straight line; zero and negative values silently drop out.
symlog is logarithmic away from zero and linear near it, for data that crosses zero.
A grid is for reading values off; the top and right spines carry no information, and removing them is the cheapest visual improvement available.

Limits, Ticks and Scales

Controlling what the axis shows and how it is written.

Limits

Set them to focus, and know what you are hiding.

example_01.pymatplotlib
Output

ax.set_xlim(a, b) and ax.set_ylim(a, b) set the visible range.

By default matplotlib picks limits slightly wider than the data, so points do not sit on the frame. ax.margins(0) removes that padding, and ax.margins(x=0.1, y=0.2) sets it per axis as a fraction.

Passing the limits in reverse order reverses the axis: ax.set_ylim(100, 0) puts 100 at the bottom. That is how you draw a rank chart where 1 is best, or a depth profile where zero is the surface.

Two cautions. Setting limits hides data rather than removing it — points outside the range are still there, still counted by anything that reads the data, just not visible. And on a bar chart, changing the y limit away from zero misstates the values, which is the subject of its own module.

ax.autoscale() returns to automatic, and ax.set_xlim(left=0) sets one end and leaves the other automatic.

Where the ticks go

set_xticks for positions, and a second argument for the labels.

example_02.pymatplotlib
Output

Formatting tick labels

A formatter changes how numbers are written without changing them.

example_03.pymatplotlib
Output

Log scale

For data spanning orders of magnitude, a linear axis shows one thing only.

example_04.pymatplotlib
Output

Symlog and other scales

When the data crosses zero, or is a proportion.

example_05.pymatplotlib
Output

Grids and spines

The lines that help, and the ones that only take up ink.

example_06.pymatplotlib
Output

A grid helps when someone will read values off the chart, and is decoration when they will not.

When it does help, a faint grid on one axis is usually enough: ax.grid(True, axis="y", alpha=0.4). A full dark grid competes with the data for attention.

ax.set_axisbelow(True) draws it behind the data, which is normally what you want and is not always the default in a given style.

The spines are the four lines around the plot. The top and right ones enclose the area and carry no information:

ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

Two lines, and the chart immediately looks less like a default. It is the highest ratio of visual improvement to effort in matplotlib, which is why nearly every published-looking style does it.

ax.spines["left"].set_position(("outward", 10)) detaches the remaining spines slightly, which is a further small refinement.

Formatters

A formatter changes how a tick value is written without changing the value.

The default for large numbers uses a shared offset — a small 1e6 in the corner — which readers routinely miss, so a chart of millions gets read as a chart of single digits.

FuncFormatter(lambda v, pos: "%.1fM" % (v / 1e6)) puts the unit on every label. The function receives the value and the tick position and returns a string.

The ready-made ones cover most needs: PercentFormatter, StrMethodFormatter("{x:,.0f}") for thousands separators, EngFormatter for SI prefixes, FormatStrFormatter("%.2f").

ax.ticklabel_format(style="plain") is the quick way to switch off scientific notation entirely.

Scales

ax.set_yscale("log") is the one that changes what a chart can show.

Data spanning orders of magnitude — populations, prices, counts, anything growing exponentially — is unreadable on a linear axis: the small values are pressed against zero and only the largest is visible. On a log axis they separate, and exponential growth becomes a straight line, which is diagnostic in itself.

Two things to know. Log requires positive values; zeros and negatives are dropped without an error, so a series that touches zero loses those points silently. And a log axis must be labelled as such, because a reader who assumes linear will misread every distance on it.

"symlog" is logarithmic away from zero and linear within a band around it, set by linthresh. It handles data that crosses zero, at the cost of a scale that changes character partway along — which needs explaining to the reader.

"logit" suits proportions, stretching the ends near 0 and 1.

Minor ticks

ax.minorticks_on() adds unlabelled ticks between the major ones, and AutoMinorLocator(5) sets how many.

They give a sense of scale without adding labels, which is useful on a log axis where the spacing between decades is not linear, and on any chart where the reader will estimate intermediate values.

ax.grid(which="minor", alpha=0.15) draws a fainter grid at the minor ticks, giving two levels of reference. That is common on engineering charts and usually too much for a presentation.

Tick direction and appearance

ax.tick_params controls everything about ticks in one call:

ax.tick_params(axis="both", direction="in", length=4,
               labelsize=9, colors="0.3")

direction="in" points the marks inward, which many publication styles prefer because it keeps the outer margin clean.

which="both" applies to major and minor together.

top=False, right=False removes ticks from the spines you have hidden — worth doing, since hiding a spine does not remove its ticks and leaving them produces marks floating in space.

Formatting money, percentages and dates

The three formatters that come up most:

from matplotlib.ticker import PercentFormatter, StrMethodFormatter, FuncFormatter

ax.yaxis.set_major_formatter(PercentFormatter(xmax=1))
ax.yaxis.set_major_formatter(StrMethodFormatter("{x:,.0f}"))
ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: f"£{v/1000:.0f}k"))

PercentFormatter(xmax=1) treats the data as fractions; xmax=100 treats it as already-percentages. Getting that backwards multiplies everything by a hundred, and the chart still looks plausible.

The thousands separator from StrMethodFormatter("{x:,.0f}") is a small change that makes large numbers much faster to read.

Symmetric limits

For diverging data, limits should usually be symmetric so zero sits in the middle:

lim = max(abs(y.min()), abs(y.max()))
ax.set_ylim(-lim, lim)

Without it, a series ranging from −2 to +8 puts zero a fifth of the way up, and the visual centre of the chart is +3 — which reads as the neutral point even though it is not.

The same reasoning applies to diverging colormaps, and for the same reason.

Shared limits across charts

Two charts compared side by side must share their limits, whether or not they are subplots of one figure.

Within a figure, sharey=True does it. Across figures, compute the range once and apply it to both:

lim = (0, max(a.max(), b.max()) * 1.05)

This is the same class of error as unshared subplots and unshared colour scales: the layout invites a comparison the axes do not support. It is easy to miss because each chart is individually correct.

Tick density

The right number of ticks is fewer than the default in most cases.

Five to eight labelled ticks on an axis is comfortable. Twelve is crowded, and the reader is not using them all.

MaxNLocator(6) caps the count while keeping the positions on round numbers, which is usually better than choosing positions by hand because it adapts when the data changes.

MultipleLocator(25) forces a fixed interval, which is right when the interval has meaning — quarters, decades, standard bin widths.

For a categorical axis, every category needs a tick, and if there are too many for the labels to fit, the answer is a different chart rather than smaller text.

Scales and honesty

A log axis changes what every distance on the chart means, and a reader who does not notice will misread every comparison on it.

Three things make it safe:

Say so in the axis label — "Population (log scale)".

Use round decade ticks, so the labels themselves announce the scale: 1, 10, 100, 1000.

Consider whether the linear version answers the question. A log scale is right when the data spans orders of magnitude or when relative change is the subject; it is wrong when absolute differences are what matter.

The same applies to any non-linear scale. The chart is not dishonest, but it depends on the reader noticing, and the labelling is what makes them notice.

Ticks that carry meaning

Tick positions are an editorial choice, not a formatting detail.

Ticks at 0, 25, 50, 75, 100 say the scale is a percentage of something. Ticks at 0, 20, 40, 60, 80, 100 say the same range is being read in twenties. Ticks at the actual data boundaries — the minimum, the median, the maximum — say something else again.

Three patterns are worth knowing.

Round numbers, which is the default behaviour and right for most continuous scales.

Meaningful values — a target, a threshold, a previous year's figure — placed explicitly so the reader can see where the data sits relative to them. ax.set_yticks(list(ax.get_yticks()) + [target]) adds one without losing the rest.

Only the endpoints, which is a minimalist treatment that works when the shape matters and precise values do not: ax.set_yticks([y.min(), y.max()]).

The last one is worth trying on a chart that feels cluttered. Most charts have more ticks than anyone uses, and each one is a small piece of visual noise competing with the data.

In summary

Limits control what is visible and hide what is not, silently.

matplotlib adds a 5% margin by default, which is right until the data should reach the edge.

set_xticks decides positions and set_xticklabels the text, and setting labels without positions attaches them to whatever ticks happen to exist — a bug that produces a plausible, wrong chart.

Formatters change how numbers read, which matters most for large values that otherwise get a 1e6 offset nobody notices.

A log scale makes exponential growth a straight line and drops zeros without a word, and it must be labelled as logarithmic or every distance on it is misread.

And the top and right spines carry no information; removing them is the cheapest improvement matplotlib offers.

Limits that adapt

Hard-coded limits are correct until the data changes, which for any chart regenerated regularly is a matter of time.

Three patterns that adapt.

Anchor one end. ax.set_ylim(bottom=0) fixes zero and lets the top follow the data — right for anything where zero is meaningful.

Pad proportionally. ax.margins(y=0.15) leaves headroom as a fraction rather than a fixed amount, so annotations near the top still fit when the values grow.

Compute from the data. lim = max(abs(y.min()), abs(y.max())) for a symmetric range around zero.

The one to avoid is set_ylim(0, 100) on a chart whose data will change, because when a value exceeds 100 the bar is silently clipped and nothing indicates it.

Where a fixed range is genuinely wanted — comparability across a series of charts — an assertion that the data fits inside it is worth the line.

One more thing

ax.invert_yaxis() reverses an axis after the fact, which is more readable than passing the limits backwards when the limits are otherwise automatic.

It is the usual way to draw a ranked list with position 1 at the top, and a depth or pressure profile where the surface is at the top — both cases where the convention of the domain runs opposite to the axis default.

The short version

Ticks and limits are where a chart quietly decides what the reader can see and how they read it.

Most charts have more ticks than anyone uses, a margin they did not choose, and a top and right spine carrying no information. Removing the excess is not decoration — it is what leaves the data as the most prominent thing on the chart.

Reading the code back

Limits, ticks and scale are three separate decisions that are usually left to defaults together. Each has a case for being set: limits when the data should reach the edge or a baseline matters, ticks when the default count is more than anyone will read, and scale when the data spans orders of magnitude. Setting all three deliberately on a chart that matters takes four lines and changes how much work the reader has to do.

Check yourself

0 of 4

Answer without scrolling back up.

  1. Why do automatic limits extend beyond the data?

  2. What goes wrong when you call `set_xticklabels` without `set_xticks`?

  3. What happens to zero values on a log axis?

  4. What is the cheapest visual improvement to a default matplotlib chart?

Cheat sheet

Limits, Ticks and Scales

Setting labels without setting positions is the classic bug. matplotlib chooses its own positions based on the current view, and set_xticklabels simply renames whatever ticks happen to exist. Change the data and the labels stay put, now attached to different values. The symptom is a chart where the labels are subtly wrong and nothing errored.

MATPLOTLIB · vizlearn.in/matplotlib/axis_limits_and_ticks.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.