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

Every axis has three layers of control: limits set what range of data is shown, ticks set where the marks and labels go, and the scale sets how values are spaced. Getting any of these wrong quietly ruins a chart without raising an error.

Limits are the simplest layer. ax.set_xlim(0, 100) pins the view. Without it, matplotlib picks the range from the data, which is usually fine but sometimes clips points or leaves too much padding. Setting limits matters most when you compare two charts side by side. If their axes cover different ranges, the reader compares lengths that mean different things.

Ticks are where most bugs live. matplotlib picks tick positions based on the current view, and those positions can change when the data changes. If you set labels without first setting positions, the labels attach to positions that may shift later. The chart looks correct at first and breaks silently when the data updates. This is the single most common axis bug, and it is why you should always set positions before labels.

The scale layer changes how values are spaced. A log scale compresses large ranges. A symmetric log scale handles data that crosses zero. Choosing the right scale can turn a chart from unreadable to clear with one line.

This page covers all three layers, with tick formatters, locators, inverted axes and minor ticks. It also covers the practical cases: date axes, percentage axes, shared limits across subplots and the offset label that makes large numbers unreadable.

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

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

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

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

ax.tick_params(axis="x", rotation=45, labelsize=9) handles rotation and size. This is the usual fix for crowded group labels. Though barh is often 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's written.

Limits

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

example_01.pymatplotlib
Output

Axis limits and ticks are the frame around a chart. They determine what range of data the reader sees, where the reference marks fall, and what numbers appear along the edge. Getting them right is the difference between a chart that communicates and one that misleads.

matplotlib picks reasonable defaults. For most quick plots, those defaults are fine. For a chart that will be shown to others, you almost always want to adjust something. Widen the x range to include a reference point. Move a tick to a round number. Remove the top and right spines. Format large numbers with commas.

This page covers limits, margins, tick placement, tick formatting, minor ticks, log and symlog scales, reversed axes, shared axes and broken axes. Each section shows the call and explains when to use it.

The most dangerous adjustment is moving the y-axis origin away from zero on a bar chart. A bar chart encodes value as length. If the baseline is not zero, the lengths are wrong and the chart lies. Line charts and scatter plots do not have this problem because they encode value as position, not length. Knowing which charts require a zero baseline and which do not prevents the most common charting mistake.

Tick placement deserves care. Too many ticks clutter the axis. Too few leave the reader guessing. matplotlib's MaxNLocator picks a reasonable number, but a chart with specific reference values -- a target, a threshold, a budget limit -- benefits from manually placed ticks at those values.

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. Points don't sit on the frame. ax.margins(0) drops that padding. 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 dropping 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. This Is the subject of its own module.

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

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. Is decoration when they won't.

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

ax.set_axisbelow(True) draws it behind the data. This Is most times what you want and isn't 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 data:

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

Two lines, and the chart at once looks less like a default. It's the highest ratio of visual gain to effort in matplotlib. This Is why nearly each published-looking style does it.

ax.spines["left"].set_position(("outward", 10)) detaches the left spines slightly. This 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. A chart of millions gets read as a chart of single digits.

FuncFormatter(lambda v, pos: "%.1fM" % (v / 1e6)) puts the unit on each label. The function receives the value and the tick spot 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 form fully.

Scales

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

Data spanning orders of magnitude. Groups, prices, counts, anything growing fast — is unreadable on a linear axis. The small values are pressed against zero and only the largest is visible. On a log axis they split. Steep growth turns a straight line. This Is diagnostic in itself.

Two things to know. Log needs plus values. Zeros and negatives are dropped without an error. A series that touches zero loses those points silently. And a log axis must be labelled as such. A reader who assumes linear will misread each 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 showing to the reader.

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

Minor ticks

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

They give a sense of scale without adding labels. This Is handy on a log axis where the spacing between decades isn't linear. On any chart where the reader will guess mid values.

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

Tick direction and appearance

ax.tick_params controls all 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. This Many publication styles prefer because it keeps the outer margin clean.

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

top=False, right=False drops ticks from the spines you've hidden. Worth doing. Hiding a spine doesn't drop its ticks and leaving them makes 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 times all by a hundred. 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 often be even 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. The visual centre of the chart is +3. Which reads as the neutral point though it isn't.

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

Shared limits across charts

Two charts checked side by side must share their limits, whether or not they're subplots of one figure.

Within a figure, sharey=True does it. Across figures, find 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 compare the axes don't support. It's easy to miss because each chart is on their own correct.

Tick density

The right number of ticks is fewer than the default often.

Five to eight labelled ticks on an axis is comfortable. Twelve is crowded. The reader isn't using them all.

MaxNLocator(6) caps the count while keeping the spots on round numbers. This Is often better than choosing spots by hand because it adapts when the data changes.

MultipleLocator(25) forces a fixed interval. This Is right when the interval has meaning. Quarters, decades, standard bin widths.

For a cat axis, each group needs a tick. If there are too many for the labels to fit, the answer is a other chart rather than smaller text.

Scales and honesty

A log axis changes what each distance on the chart means. A reader who doesn't notice will misread each compare on it.

Three things make it safe:

Say so in the axis label — "Group (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 scaled change is the subject. It's wrong when abs differences are what matter.

The same applies to any non-linear scale. The chart isn't dishonest. It depends on the reader noticing. The labelling is what makes them notice.

Ticks that carry meaning

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

Ticks at 0, 25, 50, 75, 100 say the scale is a percentage of a thing. Ticks at 0, 20, 40, 60, 80, 100 say the same range is being read in twenties. Ticks at the actual data boundaries. The min, the median, the max. Say a thing else again.

Three patterns are worth knowing.

Round numbers, which is the default action and right for most smooth scales.

Real values — a target, a threshold, a previous year's figure — placed by name so the reader can see where the data sits scaled 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 don't: 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. Each one is a small piece of visual noise competing with the data.

Limits, Ticks and Scales, in summary

Limits control what is visible and hide what isn't, silently.

matplotlib adds a 5% margin by default. This Is right until the data should reach the edge.

set_xticks decides spots and set_xticklabels the text. Setting labels without spots attaches them to what ticks happen to exist. A bug that makes a plausible, wrong chart.

Formatters change how numbers read. This Matters most for large values that else get a 1e6 offset no one notices.

A log scale makes steep growth a straight line and drops zeros without a word. It must be labelled as logarithmic or each distance on it's misread.

And the top and right spines carry no data. Dropping them is the cheapest gain matplotlib offers.

Limits that adapt

Hard-coded limits are correct until the data changes. This 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 real.

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

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

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

Where a fixed range is truly wanted. Comparability across a series of charts. An assertion that the data fits inside it's worth the line.

One more thing

ax.invert_yaxis() reverses an axis after the fact. This Is more readable than passing the limits backwards when the limits are else auto.

It's the usual way to draw a ranked list with spot 1 at the top. A depth or pressure profile where the surface is at the top. Both cases where the rule of the domain runs opposite to the axis default.

Limits, Ticks and Scales: 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 didn't choose. A top and right spine carrying no data. Dropping the excess isn't decoration. It's what leaves the data as the most prominent thing on the chart.

Questions people ask about Limits, Ticks and Scales

How do I set the axis range? Use ax.set_xlim and ax.set_ylim, or ax.set(xlim=..., ylim=...). Pass None for one side to leave it automatic.

How do I reverse an axis? Swap the limits, as in ax.set_ylim(top, bottom), or call ax.invert_yaxis().

How do I choose where ticks fall? Use ax.set_xticks with a list of positions, or a locator such as MultipleLocator(5) for regular spacing.

How do I format tick labels as percentages or currency? Use a formatter, such as PercentFormatter or a FuncFormatter with your own function.

When should I use a log scale? When values span several orders of magnitude, or when ratios matter more than differences. Say on the axis that it is logarithmic.

Why is my axis range slightly larger than my data? matplotlib adds margins around the data. Change them with ax.margins(0) or ax.set_xlim.

Reading the code back

Limits, ticks and scale are three split choices that are often 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. Scale when the data spans orders of magnitude. Setting all three on purpose 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

Set axis limits, tick positions, tick labels and log scales in matplotlib. Control exactly what the axis shows and how it reads.

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.