Dates on an Axis

Time series plotting - the locators and formatters that stop the labels colliding.

Overview

The axis type follows the data

Time series are the most common data in business and science. Revenue over months, temperature over years, price over minutes. Plotting them correctly requires a date-aware axis. matplotlib can build one automatically, but only when the data arrives in the right type.

The distinction between a date axis and a categorical axis is the single most important thing on this page. A date axis spaces points proportionally to the time between them. A categorical axis spaces them evenly. The difference is invisible when the data is evenly spaced and catastrophic when it is not.

A dataset with readings on January 1, January 2, and June 15 should show a large gap between January and June. A date axis does this. A categorical axis draws all three points equidistant. The line connecting them implies data that does not exist. That misrepresentation is silent. Nothing warns you.

The fix is simple: make sure the data reaches matplotlib as datetime objects, not as strings. pandas to_datetime does the conversion. Checking the dtype before plotting catches the problem. These two habits prevent the most common time-series plotting mistake.

This page covers automatic date detection, locators and formatters for controlling tick placement and label format, timezone handling, date ranges, broken date axes and the common mistakes that produce misleading time-series charts. It also covers the case where your dates are fiscal quarters or academic terms and need custom formatting.

Locators and formatters give you precise control over which dates get a tick and how they are labelled. MonthLocator places a tick on the first of each month. DateFormatter("%b %Y") labels it as "Jan 2024". Combining the two gives clean, readable date axes regardless of the time span.

Pass a list of datetime.date or datetime.datetime objects. Or a pandas DatetimeIndex, or NumPy datetime64. And matplotlib recognises them and builds a date axis. Tick spots and labels are then chosen in date units rather than any numbers.

Pass strings and you get a thing quite other. Matplotlib treats them as groups. Each distinct string gets a spot, evenly spaced, in the order supplied.

That gap matters more than it sounds. With groups, two readings a day apart and two readings two months apart are drawn the same distance apart. The gap in the data disappears. The line implies a continuity that isn't there.

Since dates from a CSV arrive as strings, this is a common and quiet failure. pd.to_datetime before plotting is the fix. Checking the dtype is how you notice.

Worth knowing

Pass real date or datetime objects and matplotlib builds a date axis; the type of the x data decides the axis type.
Strings are categories — evenly spaced in the order given, so gaps between dates disappear.
Locators place the ticks: MonthLocator, WeekdayLocator, DayLocator, with interval=.
Formatters decide how they read; ConciseDateFormatter drops repeated parts like the year.
For colliding labels, prefer fewer ticks, then shorter labels, and rotation only as a last resort.
axvspan and axvline take dates directly, because the axis is numeric underneath — days since an epoch.

Dates on an Axis

Time series plotting, and the locators and formatters that stop labels colliding.

Real dates plot as dates

Pass datetimes and matplotlib gives you a date axis for free.

example_01.pymatplotlib
Output

Strings are not dates

They plot in the order given, evenly spaced, whatever the gaps.

example_02.pymatplotlib
Output

Locators decide where the ticks go

By month, by week, by day - rather than at arbitrary numbers.

example_03.pymatplotlib
Output

Formatters decide how they read

strftime codes, and a concise formatter that avoids repetition.

example_04.pymatplotlib
Output

Rotating and thinning labels

When the labels still collide.

example_05.pymatplotlib
Output

Marking a period

A date axis takes the same spans and lines as any other.

example_06.pymatplotlib
Output

Locators

A locator decides where ticks go. The date locators think in calendar units:

YearLocator(), MonthLocator(), WeekdayLocator(byweekday=MO), DayLocator(), HourLocator(), MinuteLocator().

Each takes an interval, so MonthLocator(interval=3) gives quarterly ticks. A few take a by... arg. DayLocator(bymonthday=1) for the first of each month.

AutoDateLocator() chooses based on the visible range, and adapts if the axis is zoomed.

Minor ticks add structure without labels: monthly majors with weekly minors gives a sense of scale without crowding.

Formatters

A formatter decides how a tick reads.

DateFormatter("%d %b") uses strftime codes: %Y four-digit year, %y two-digit, %m month number, %b abbreviated month name, %d day, %H:%M time.

ConciseDateFormatter(locator) is often the better choice. It drops what is repeated. Printing the year once at the left rather than on each label. Showing only the day number when the month is unchanged. That is what a person would do by hand. It saves a great deal of flat space.

It needs the locator passed to it. It decides what to omit based on the tick spacing.

Crowded labels

Date labels collide more than any other kind. They're long and there are many of them.

Three fixes, in order of preference:

Fewer ticks. A coarser locator. Fifteen readable ticks beat forty shared ones. The reader was never going to use all forty.

Shorter labels. "%d %b" instead of "%Y-%m-%d", or ConciseDateFormatter.

Rotation. fig.autofmt_xdate() rotates the labels and right-aligns them. Also makes room. It works, and slanted text is truly harder to read than flat text. It belongs after the other two rather than instead of them.

A wider figure is the fourth option and often the honest one. A year of daily data doesn't fit legibly in four inches.

Marking events

axvline(date) and axvspan(start, end) take dates right.

That works because the date axis is numeric underneath. Matplotlib converts dates to floating-point days since an epoch, plots the numbers. Formats the labels back into dates. mdates.date2num and num2date do the conversion by name when you need it.

The same fact shows why set_xlim accepts either dates or raw numbers. Why math on the limits works.

With pandas

A pandas Series with a DatetimeIndex plots right:

ax.plot(series.index, series.values)

or series.plot(ax=ax), which uses pandas' own date formatting. The two give slightly other tick choices. Pandas' version is often good enough that no locator work is needed.

For a DataFrame, df.plot(ax=ax) draws each column against the index. This Is the fastest route from a time-indexed frame to a chart. And the subject of a later module.

Time zones and the axis

matplotlib converts datetimes to numbers using a fixed epoch. Time-zone-aware timestamps are converted to the axis's timezone before plotting.

rcParams["timezone"] sets it. If it doesn't match the data's, the labels are correct times in a other zone. The shift is a whole number of hours. Large enough to matter and small enough to overlook.

The solid approach is the one from the pandas track. Norm to a single zone before plotting. Label the axis with which one.

Gaps and non-trading days

A date axis draws real elapsed time. So A weekend appears as a gap.

For money data that is often unwanted: five trading days a week become a line with regular breaks. A month of data has eight or nine of them.

Two options. Plot against an int index and label the ticks with the dates. This Drops the gaps at the cost of a slightly dishonest axis. Or accept the gaps. This Is more truthful about elapsed time.

Which is right depends on whether the chart is about the market's action over trading sessions or about calendar time. Both are defensible. Picking without noticing isn't.

Resampling before plotting

A year of per-minute data is half a million points. A chart seven inches wide has seven hundred pixel columns.

Resampling to a sensible rate before plotting is often better than drawing all:

daily = series.resample("D").mean()
ax.plot(daily.index, daily.values)

That is fewer artists, a smaller file. A chart that looks same. Because the extra points were never distinguishable.

Where extremes matter, resampling to min and max per period and shading between them keeps the envelope. This Is the technique from the speed module.

Multiple time series with different ranges

Two series covering other date ranges plot happily on one axis. The shorter one simply occupies part of the width.

That is correct and can mislead. A series that starts later looks like it began at zero rather than being unobserved. Making the gap clear. A note, or a shaded region marking where data exists. Blocks the reading that a thing changed at that date.

The same applies to a series with a gap in the middle. Here The broken line is the honest display and a joined one isn't.

Annotating dates

each annotation method takes dates right, because the axis is numeric underneath.

The pattern for marking an event:

ax.axvline(event_date, color="crimson", linestyle="--", linewidth=1)
ax.text(event_date, 1.01, " launch", transform=ax.get_xaxis_transform(),
        rotation=0, fontsize=9, color="crimson")

get_xaxis_transform() puts x in data units and y in axes fractions. The label sits just above the plot even so of the data's range. Which is what you want for an event marker that should stay at the top.

Reading a time axis

Three things a reader needs from a dated axis, and which are often missing.

The period covered, which the first and last tick imply and a title states.

The granularity — whether a point is a day, a week or a month — which markers make clear and a smooth line hides.

Whether gaps are real. A break in a line means missing data. A gap on the axis with the line continuing means the axis is cat and the elapsed time isn't being shown.

Stating the rate in the title or the axis label. "Daily", "Monthly mean". Costs a word and answers all three.

Common date problems

Labels shared — too many ticks. Use a coarser locator before rotating.

Dates as groups — strings not converted. Gaps disappear and the spacing is wrong.

A shifted axis — a time-zone mismatch between the data and rcParams["timezone"].

Ticks in odd places — an auto locator on an unusual range. Name the locator.

A rate string that no longer works. Pandas 2.2 renamed a few of them.

The line breaking at weekends. Real elapsed time. This Is either correct or a reason to plot against an index.

The first two account for most of it, and both are visible at once.

Aggregating before plotting

Most time-series charts are better after aggregation than before it.

Per-minute data over a year is half a million points and roughly seven hundred pixel columns. Resampling to daily gives 365 points, a chart that draws instantly. A picture that is visually same because the extra points were never separable.

The choice of aggregation is a choice about what the chart is for:

Mean for a level — the common value in each period.

Sum for a quantity — total sales per week.

Min and max shaded as a band, when the extremes are what matter and a mean would hide them.

Last for a state or a price. Here The value at the end of the period is the real one.

Plotting raw high-rate data and letting the renderer overplot it isn't more honest. It's the same data rendered less legibly, with what the shared happens to leave visible standing in for a summary you didn't choose.

Dates on an Axis, in summary

Pass real datetimes and matplotlib builds a date axis. Pass strings and it builds a cat one where gaps disappear.

pd.to_datetime with an clear format is faster and drops the day-first ambiguity.

Locators place the ticks in calendar units and formatters decide how they read, with ConciseDateFormatter dropping the repetition.

For crowded labels, fewer ticks first, shorter labels second, rotation last.

axvline and axvspan take dates right, because the axis is numeric underneath.

And pandas 2.2 renamed the rate aliases. ME, h, min. Which is the usual reason a copied example stops working.

Periods rather than instants

Much time-series data is about periods rather than points: monthly totals, weekly means, daily counts.

Plotting a period as a point at its start implies the value occurred at that instant. A line between two such points implies a smooth transition through the month.

Two displays are more honest for period data.

Bars, one per period. This Say "this is the total for this interval" and have no between-values.

A step line, ax.step(..., where="post"). This Holds the value across the period and changes at the boundary.

A line is still fair when the periods are short scaled to the trend and the shape is the point. But it's a choice. For something like monthly revenue a bar chart is often the better display and rarely the one reached for.

Labelling the axis with the period. "Month starting". Drops the left ambiguity about which end a point shows.

A closing note on Dates on an Axis

Time axes carry more rules than any other kind. Most of the work is in the setup rather than the drawing.

Parse the dates properly, check the dtype, decide on a time zone, sort. Choose a rate that matches the question. After that, the plotting is the same as any other line chart.

The two failures that matter are plotting strings instead of dates. This Silently drops the gaps and misrepresents elapsed time. Letting the locator give more labels than can be read. This Is fixed by asking for fewer rather than by rotating them.

And rate aliases changed in pandas 2.2. This Accounts for a large share of examples that no longer run.

Dates on an Axis: the short version

A date axis is the gap between a chart of a time series and a chart of some values in order.

Parse properly, sort, choose a rate that matches the question. Use fewer ticks than the default. The rest follows from the line-chart material.

Questions people ask about Dates on an Axis

Why are my dates plotted as strings? The values are text, not datetimes. Convert with pd.to_datetime or np.datetime64, then matplotlib builds a date axis.

How do I control where date ticks go? Use locators such as mdates.MonthLocator() or AutoDateLocator, and set formats with mdates.DateFormatter('%b %Y').

How do I fix overlapping date labels? Call fig.autofmt_xdate() to rotate them, or use ConciseDateFormatter for compact labels.

How do I mark an event on a time axis? Use ax.axvline with a datetime value, and add a label with ax.annotate.

Why does my line connect weekends or holidays? A date axis is continuous. Closed days leave gaps that a line bridges. Plot the data as points or insert NaN to break the line.

What do the codes in DateFormatter mean? They use strftime: %Y year, %m month number, %b month name, %d day, %H:%M time.

Reading the code back

A time-series chart is a line chart with three extra choices. How the dates were parsed. This Locator places the ticks. Which formatter writes them. All three are made before any styling. Getting the first wrong makes the other two irrelevant because the axis isn't a timeline at all.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What happens if you plot date strings rather than date objects?

  2. What does a locator do?

  3. Why is `ConciseDateFormatter` usually better than `DateFormatter`?

  4. Date labels are colliding. What should you try first?

Cheat sheet

Dates on an Axis

Plot time series in matplotlib with proper date axes. Set locators and formatters so that date labels stay readable without overlapping.

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