Time series plotting - the locators and formatters that stop the labels colliding.
Overview
The axis type follows the data
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 positions and labels are then chosen in date units rather than arbitrary numbers.
Pass strings and you get something quite different: matplotlib treats them as categories. Each distinct string gets a position, evenly spaced, in the order supplied.
That difference matters more than it sounds. With categories, two readings a day apart and two readings two months apart are drawn the same distance apart. The gap in the data disappears, and the line implies a continuity that is not there.
Since dates from a CSV arrive as strings, this is a common and quiet failure. pd.to_datetime before plotting is the fix, and 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:
Each takes an interval, so MonthLocator(interval=3) gives quarterly ticks, and several take a by... argument — 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.
ConciseDateFormatter(locator) is usually the better choice. It drops what is repeated — printing the year once at the left rather than on every label, and showing only the day number when the month is unchanged. That is what a person would do by hand, and it saves a great deal of horizontal space.
It needs the locator passed to it, because it decides what to omit based on the tick spacing.
Crowded labels
Date labels collide more than any other kind, because they are long and there are many of them.
Three fixes, in order of preference:
Fewer ticks. A coarser locator. Fifteen readable ticks beat forty overlapping ones, and 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, and also makes room. It works, and slanted text is genuinely harder to read than horizontal text, so 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 does not fit legibly in four inches.
Marking events
axvline(date) and axvspan(start, end) take dates directly.
That works because the date axis is numeric underneath — matplotlib converts dates to floating-point days since an epoch, plots the numbers, and formats the labels back into dates. mdates.date2num and num2date do the conversion explicitly when you need it.
The same fact explains why set_xlim accepts either dates or raw numbers, and why arithmetic on the limits works.
With pandas
A pandas Series with a DatetimeIndex plots directly:
ax.plot(series.index, series.values)
or series.plot(ax=ax), which uses pandas' own date formatting. The two produce slightly different tick choices, and pandas' version is often good enough that no locator work is needed.
For a DataFrame, df.plot(ax=ax) draws every column against the index, which 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, and time-zone-aware timestamps are converted to the axis's timezone before plotting.
rcParams["timezone"] sets it. If it does not match the data's, the labels are correct times in a different zone, and the shift is a whole number of hours — large enough to matter and small enough to overlook.
The reliable approach is the one from the pandas track: normalise to a single zone before plotting, and 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 financial data that is usually unwanted: five trading days a week become a line with regular breaks, and a month of data has eight or nine of them.
Two options. Plot against an integer index and label the ticks with the dates, which removes the gaps at the cost of a slightly dishonest axis. Or accept the gaps, which is more truthful about elapsed time.
Which is right depends on whether the chart is about the market's behaviour over trading sessions or about calendar time. Both are defensible; picking without noticing is not.
Resampling before plotting
A year of per-minute data is half a million points, and a chart seven inches wide has seven hundred pixel columns.
Resampling to a sensible frequency before plotting is usually better than drawing everything:
That is fewer artists, a smaller file, and a chart that looks identical — because the extra points were never distinguishable.
Where extremes matter, resampling to min and max per period and shading between them keeps the envelope, which is the technique from the performance module.
Multiple time series with different ranges
Two series covering different date ranges plot happily on one axis, and the shorter one simply occupies part of the width.
That is correct and can mislead, because a series that starts later looks like it began at zero rather than being unobserved. Making the difference explicit — a note, or a shaded region marking where data exists — prevents the reading that something changed at that date.
The same applies to a series with a gap in the middle, where the broken line is the honest display and a joined one is not.
Annotating dates
Every annotation method takes dates directly, because the axis is numeric underneath.
get_xaxis_transform() puts x in data units and y in axes fractions, so the label sits just above the plot regardless 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 frequently 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 explicit 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 categorical and the elapsed time is not being shown.
Stating the frequency in the title or the axis label — "Daily", "Monthly average" — costs a word and answers all three.
Common date problems
Labels overlapping — too many ticks; use a coarser locator before rotating.
Dates as categories — 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 automatic locator on an unusual range; name the locator.
A frequency string that no longer works — pandas 2.2 renamed several of them.
The line breaking at weekends — real elapsed time, which is either correct or a reason to plot against an index.
The first two account for most of it, and both are visible immediately.
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, and a picture that is visually identical because the extra points were never separable.
The choice of aggregation is a decision about what the chart is for:
Mean for a level — the typical 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, where the value at the end of the period is the meaningful one.
Plotting raw high-frequency data and letting the renderer overplot it is not more honest; it is the same information rendered less legibly, with whatever the overlapping happens to leave visible standing in for a summary you did not choose.
In summary
Pass real datetimes and matplotlib builds a date axis; pass strings and it builds a categorical one where gaps disappear.
pd.to_datetime with an explicit format is faster and removes 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 directly, because the axis is numeric underneath.
And pandas 2.2 renamed the frequency 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 averages, daily counts.
Plotting a period as a point at its start implies the value occurred at that instant, and 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, which say "this is the total for this interval" and have no between-values.
A step line, ax.step(..., where="post"), which holds the value across the period and changes at the boundary.
A line is still reasonable when the periods are short relative to the trend and the shape is the point — but it is a choice, and for something like monthly revenue a bar chart is frequently the better display and rarely the one reached for.
Labelling the axis with the period — "Month starting" — removes the remaining ambiguity about which end a point represents.
A closing note
Time axes carry more conventions than any other kind, and 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, and choose a frequency 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, which silently removes the gaps and misrepresents elapsed time, and letting the locator produce more labels than can be read, which is fixed by asking for fewer rather than by rotating them.
And frequency aliases changed in pandas 2.2, which accounts for a large share of examples that no longer run.
The short version
A date axis is the difference between a chart of a time series and a chart of some values in order.
Parse properly, sort, choose a frequency that matches the question, and use fewer ticks than the default. The rest follows from the line-chart material.
Reading the code back
A time-series chart is a line chart with three extra decisions: how the dates were parsed, which locator places the ticks, and which formatter writes them. All three are made before any styling, and getting the first wrong makes the other two irrelevant because the axis is not a timeline at all.
Check yourself
0 of 4
Answer without scrolling back up.
What happens if you plot date strings rather than date objects?
Dates from a CSV arrive as strings, so this is a common quiet failure. The line implies a continuity that is not there.
What does a locator do?
Formatters decide how ticks read; locators decide where they are. Date locators think in calendar units.
Why is `ConciseDateFormatter` usually better than `DateFormatter`?
It needs the locator passed to it, because what it omits depends on the tick spacing.
Date labels are colliding. What should you try first?
Then shorter labels, and rotation last - slanted text is genuinely harder to read than horizontal text.
Cheat sheet
Dates on an Axis
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 positions and labels are then chosen in date units rather than arbitrary numbers.
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.