Most of the charts in this textbook have treated data as unordered — a scatter plot does not care about the order of its points, a histogram does not care about the order of its observations, a heatmap is a matrix without temporal structure. Time...
Learning Objectives
- Create time series line charts with proper datetime axis formatting, locators, and formatters
- Visualize trend decomposition (trend + seasonal + residual) using statsmodels and matplotlib
- Add rolling means and exponential smoothing overlays to time series charts
- Highlight anomalies, change points, and regime changes with visual annotation techniques
- Create seasonal pattern visualizations: calendar heatmaps, cycle plots, seasonal subseries plots
- Handle irregular time series, missing data, and multiple time scales on a single chart
- Apply aspect ratio principles (banking to 45 degrees) for accurate slope perception
- Create sparklines for compact multi-series time series comparison
In This Chapter
- 25.1 What Makes Time Series Special
- 25.2 Datetime Axes: The Foundation
- 25.3 Rolling Means and Smoothing
- 25.4 Seasonal Decomposition
- 25.5 Highlighting Anomalies and Change Points
- 25.6 Calendar Heatmaps
- 25.7 Cycle Plots and Seasonal Subseries
- 25.8 Sparklines
- 25.9 Aspect Ratio and Banking to 45 Degrees
- 25.10 Interactive Time Series with Plotly
- 25.11 Time Series Pitfalls
- 25.12 Progressive Project: Comprehensive Climate Time Series
- 25.13 Working with Dates in pandas
- 25.14 Multi-Series Time Series Charts
- 25.15 Log Scales for Time Series
- 25.16 Real-Time and Streaming Time Series
- 25.17 Forecasts and Confidence Intervals
- 25.18 A Note on Data Frequency Mismatches
- 25.19 Event Markers and Context Lines
- 25.20 Check Your Understanding
- 25.21 Chapter Summary
- 25.22 Spaced Review
Chapter 25: Time Series Visualization — Trends, Seasonality, and Change Over Time
"The past is never dead. It's not even past." — William Faulkner, Requiem for a Nun (1951)
25.1 What Makes Time Series Special
Most of the charts in this textbook have treated data as unordered — a scatter plot does not care about the order of its points, a histogram does not care about the order of its observations, a heatmap is a matrix without temporal structure. Time series data is different. The observations have a specific order, each tied to a moment in time, and the relationships between nearby observations are usually meaningful. The temperature on March 5 is related to the temperature on March 4; a stock price this minute is related to the price a minute ago; a website's traffic at 9 AM is related to its traffic at 8:59. The ordering carries information that a reshuffled version of the data would destroy.
This ordering creates a specific set of visualization challenges and opportunities. The challenge is that temporal data has structure at many scales — daily cycles, weekly patterns, monthly seasonality, annual cycles, long-term trends — and a single chart cannot show all of them at once. The opportunity is that the reader already has a mental model of time. A viewer looking at a line chart with "2020" and "2024" on the x-axis does not need to be told what the axis represents. Time is so fundamental to human experience that any chart indexed by time inherits the reader's intuitions about dates, seasons, cycles, and change.
This chapter covers the techniques and tools for visualizing time series well. It does not introduce a new threshold concept — the ideas build on material from earlier chapters (line charts from Chapter 11, heatmaps from Chapter 14, interactive charts from Chapter 20) and apply them to the specific case of time-indexed data. What is new is the vocabulary: datetime axes, locators and formatters, rolling means, decomposition, anomalies, calendar heatmaps, sparklines. Each is a specific tool for a specific question about temporal data.
The progressive climate project finally shines in this chapter. The climate dataset spans 150 years, making it a near-perfect case study for every time series technique. We will decompose temperature into trend, seasonal, and residual components; overlay rolling means to reveal long-term patterns; annotate anomalies and change points; and build calendar heatmaps to show monthly and seasonal variation. By the end, you will have a toolkit for answering almost any question about how something changes over time.
25.2 Datetime Axes: The Foundation
Before you can visualize a time series, you have to handle the x-axis correctly. A "datetime axis" is an axis whose values are dates or timestamps rather than plain numbers, and matplotlib, seaborn, and Plotly all support it — but each has its own conventions, and getting the formatting right is the first step of any time series visualization.
In pandas, time series data is typically stored as a DataFrame with a DatetimeIndex:
import pandas as pd
dates = pd.date_range(start="2020-01-01", end="2024-12-31", freq="D")
df = pd.DataFrame({"value": np.random.randn(len(dates)).cumsum()}, index=dates)
df.plot(y="value") # matplotlib picks up the DatetimeIndex automatically
When the index is a DatetimeIndex, matplotlib (via pandas's plotting wrapper) formats the x-axis as dates automatically. It chooses tick positions and label formats based on the date range: for a multi-year chart, it shows years; for a multi-month chart, it shows months; for a multi-day chart, it shows days. This "automatic" behavior is usually adequate but often needs tuning.
To control the tick positions and formats manually, use matplotlib.dates:
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(df.index, df["value"])
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
ax.xaxis.set_minor_locator(mdates.MonthLocator())
fig.autofmt_xdate()
The YearLocator places a major tick at each year; the DateFormatter("%Y") formats them as four-digit years; the MonthLocator places minor ticks at each month. fig.autofmt_xdate() rotates the x-labels to avoid overlap. Other locators include DayLocator, WeekdayLocator, HourLocator, MinuteLocator, and AutoDateLocator (which picks a reasonable one automatically).
The DateFormatter uses strftime-style format strings:
"%Y"— four-digit year (2024)"%Y-%m"— year and month (2024-03)"%b %Y"— abbreviated month and year (Mar 2024)"%Y-%m-%d"— ISO date (2024-03-15)"%H:%M"— 24-hour time (14:30)
A common pattern for time series that span multiple years is to use a major locator for years and a minor locator for months, with a format that shows only the year on the major ticks:
ax.xaxis.set_major_locator(mdates.YearLocator(base=5)) # every 5 years
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
ax.xaxis.set_minor_locator(mdates.YearLocator(base=1)) # every year (minor)
For Plotly, the pattern is simpler because Plotly handles datetime axes automatically:
import plotly.express as px
fig = px.line(df, x=df.index, y="value")
fig.update_xaxes(tickformat="%Y", dtick="M12")
The tickformat argument uses d3-format-style date specifiers. dtick="M12" says "tick every 12 months." For other intervals: "D1" (every day), "M1" (every month), "M3" (every quarter), "M6" (every half year), 604800000 (one week in milliseconds for ms-based axes).
The general rule: pick a tick density that makes the axis readable but not cluttered. For a 5-year chart, 5 ticks (one per year) is usually right. For a 50-year chart, 5-10 ticks (every 5-10 years) works better. For a 1-day chart, ticks every hour or two. The wrong density — too many ticks or too few — makes the chart harder to read.
25.3 Rolling Means and Smoothing
Raw time series data is often noisy — short-term fluctuations obscure the underlying trend. The standard response is to smooth the data with a rolling mean (or moving average): for each point, compute the average of the nearby points in a window around it. The result is a smoothed version of the original series that emphasizes the trend at the expense of short-term detail.
pandas makes this trivial:
df["rolling_7"] = df["value"].rolling(window=7).mean() # 7-day rolling mean
df["rolling_30"] = df["value"].rolling(window=30).mean() # 30-day rolling mean
df["rolling_365"] = df["value"].rolling(window=365).mean() # 1-year rolling mean
The rolling(window=N).mean() computes a simple moving average with an N-length window. The first N-1 values are NaN because there is not enough history to compute the mean. Other rolling operations are available: .sum(), .median(), .std(), .min(), .max(), .quantile(0.95), and custom functions via .apply(func).
Visualizing the raw series alongside one or more rolling means is a common pattern:
fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(df.index, df["value"], color="lightgray", linewidth=0.5, label="Daily")
ax.plot(df.index, df["rolling_30"], color="steelblue", linewidth=1.5, label="30-day MA")
ax.plot(df.index, df["rolling_365"], color="darkred", linewidth=2, label="1-year MA")
ax.legend(loc="upper left")
ax.set_title("Raw Series with Rolling Means")
The layered chart lets the reader see both the short-term noise and the long-term trend. The raw series gives context; the rolling means give interpretation. This is one of the most common time series visualizations in practice.
Window size matters. A small window (7 days for daily data) removes only high-frequency noise; a large window (365 days) captures only long-term trend. The right choice depends on the question. For climate temperature data, a 365-day rolling mean is essential to remove seasonality — without it, you cannot see the long-term warming signal through the annual cycle. For daily stock prices, a 7-day or 30-day rolling mean is typical for the short-term trend. Choosing the window is a design decision that should be disclosed in the caption.
Exponential moving average (EMA) is an alternative smoothing method that weights recent observations more heavily than older ones:
df["ema"] = df["value"].ewm(span=30, adjust=False).mean()
The span=30 argument is the pandas equivalent of a 30-day window, but with exponential decay. EMA reacts more quickly to recent changes than a simple moving average, which makes it preferred in some financial and time-series-analysis contexts. Visually, EMA and SMA look similar on most data, with EMA having slightly less lag.
Centered rolling means are a variation where each point's average is computed from points before AND after it. Set center=True in the rolling() call:
df["centered_30"] = df["value"].rolling(window=30, center=True).mean()
Centered windows have a subtle advantage: they do not lag behind the raw data the way left-aligned windows do. The trade-off is that the last window/2 points have NaN because there is no "future" data to include. Centered windows are preferred for historical analysis where you have both past and future data; they are impossible for real-time monitoring where you only have the past.
25.4 Seasonal Decomposition
Many time series have a repeating seasonal pattern — an annual cycle (temperature, tourism), a weekly cycle (web traffic, retail sales), or a daily cycle (electricity demand). Seasonal decomposition separates a time series into three components:
- Trend: the long-term direction (increasing, decreasing, flat).
- Seasonal: the repeating pattern within each cycle.
- Residual: what is left after removing trend and seasonal — the noise or irregular component.
The classical decomposition model is additive (observed = trend + seasonal + residual) or multiplicative (observed = trend × seasonal × residual). The additive form is appropriate when seasonal variation is roughly constant over time; multiplicative is appropriate when seasonal variation grows proportionally with the trend.
Python's statsmodels library provides the seasonal_decompose function:
from statsmodels.tsa.seasonal import seasonal_decompose
result = seasonal_decompose(df["value"], model="additive", period=365)
# result has .trend, .seasonal, .resid, .observed attributes
The function computes the three components and returns a DecomposeResult object. You can visualize the decomposition as a 4-panel figure:
fig, axes = plt.subplots(4, 1, figsize=(12, 10), sharex=True)
axes[0].plot(result.observed); axes[0].set_ylabel("Observed")
axes[1].plot(result.trend, color="darkred"); axes[1].set_ylabel("Trend")
axes[2].plot(result.seasonal, color="steelblue"); axes[2].set_ylabel("Seasonal")
axes[3].plot(result.resid, color="gray"); axes[3].set_ylabel("Residual")
fig.suptitle("Seasonal Decomposition")
plt.tight_layout()
The four panels together tell a complete story: the observed series, the slow-moving trend underneath it, the repeating seasonal cycle, and the residual noise. For climate data, the trend panel shows the century-scale warming signal; the seasonal panel shows the annual temperature cycle; the residual panel shows year-to-year variability driven by ENSO and other climate oscillations. The decomposition is more informative than any single panel alone.
A more modern decomposition method is STL (Seasonal-Trend decomposition using LOESS), which handles changing seasonal patterns and is more robust to outliers:
from statsmodels.tsa.seasonal import STL
stl = STL(df["value"], period=365, robust=True).fit()
# same .trend, .seasonal, .resid attributes
STL is generally preferred for real-world data because it allows the seasonal component to evolve over time (useful when the seasonal amplitude grows or shrinks) and is less sensitive to outliers.
25.5 Highlighting Anomalies and Change Points
A time series chart with thousands of points often has one or two interesting events — the anomaly that caused a spike, the change point where a trend reversed, the regime change where the statistical properties shifted. Annotating these events visually turns a dense chart into a narrative.
The main techniques:
Highlight individual anomalies with marker emphasis:
anomalies = df[df["value"] > df["value"].mean() + 3 * df["value"].std()]
ax.plot(df.index, df["value"], color="steelblue")
ax.scatter(anomalies.index, anomalies["value"], color="red", s=50, zorder=5, label="Anomaly")
A scatter overlay on top of the line draws the reader's attention to specific points. The zorder=5 ensures the scatter is drawn on top of the line.
Shade regions of concern with fill_between:
ax.fill_between(df.index, 0, df["value"], where=(df["value"] > threshold),
color="red", alpha=0.3, label="Above threshold")
fill_between with a where condition shades only the parts of the chart meeting the condition. This is useful for highlighting periods rather than individual points.
Mark change points with vertical lines and annotations:
ax.axvline(pd.Timestamp("2020-03-15"), color="red", linestyle="--", alpha=0.7)
ax.annotate("COVID lockdown", xy=(pd.Timestamp("2020-03-15"), ax.get_ylim()[1]),
xytext=(10, -20), textcoords="offset points",
ha="left", fontsize=9, color="red")
A vertical line at a specific date, combined with an annotation, tells the reader what happened at that point. This is the action-title approach (Chapter 7) applied to specific events within the chart.
Shade regime changes with axvspan:
ax.axvspan(pd.Timestamp("2008-09"), pd.Timestamp("2009-06"),
alpha=0.2, color="gray", label="2008 recession")
axvspan shades a vertical band between two dates — useful for marking periods like recessions, wars, product launches, or any bounded event.
The choice between these techniques depends on the number of events and the story you are telling. For one or two major events, annotations and vertical lines are clearest. For many anomalies, colored scatter dots are more efficient. For ongoing regimes, shaded bands work best.
25.6 Calendar Heatmaps
A calendar heatmap is a 2D display where one axis is "week of the year" or "month" and the other is "day of week" or "day of month," with color encoding the value. It compresses daily data for a full year (or more) into a compact, scannable display that reveals weekly cycles, seasonal patterns, and individual outlier days at a glance.
The calplot library produces calendar heatmaps directly from a pandas Series:
import calplot
calplot.calplot(df["value"], cmap="YlOrRd", suptitle="Daily Values by Calendar Year")
The output is one row of calendar cells per year, with days colored by value. A good calendar heatmap reveals:
- Weekly cycles: if weekend values differ from weekday values, the heatmap shows alternating column colors.
- Monthly patterns: if month-end or month-start has characteristic values, they appear as vertical stripes.
- Seasonal patterns: if summer values differ from winter values, they appear as horizontal bands.
- Individual anomalies: single dark cells stand out against the background.
Without calplot, you can build a calendar heatmap manually with matplotlib by reshaping the data into a 2D array (rows = weeks, columns = days of week) and using imshow or pcolormesh. It is more work but gives you full styling control.
Calendar heatmaps are a Tufte favorite and are underused in business dashboards. A year of daily data fits into about the same visual space as a single medium-sized line chart but conveys more fine-grained detail. Consider one for any daily metric that stakeholders review.
25.7 Cycle Plots and Seasonal Subseries
When the seasonal pattern is the main story, a cycle plot (also called a seasonal subseries plot) provides a specialized view. Instead of one long line chart, you show one small panel per season (month, quarter, or similar), with each panel showing the values for that season across multiple years. The panels are arranged in seasonal order, and a horizontal reference line shows the season's overall average.
For climate data with monthly aggregates:
fig, axes = plt.subplots(3, 4, figsize=(14, 8), sharey=True)
for i, month in enumerate(range(1, 13)):
ax = axes.flat[i]
month_data = df[df.index.month == month]
ax.plot(month_data.index.year, month_data["value"], marker="o")
ax.axhline(month_data["value"].mean(), color="red", linestyle="--")
ax.set_title(month_names[i])
Each panel shows the values for one month across all years, with a red reference line at the month's long-term mean. The reader can see which months are trending up, which are flat, and which show the most variability. For climate data, this reveals that winter months in the Northern Hemisphere are warming faster than summer months — a pattern that a single line chart would obscure.
Cycle plots are niche but powerful. Use them when the seasonal-vs-trend comparison is the main analytical question.
25.8 Sparklines
Sparklines are very small line charts, about the size of a word, designed to sit inline with text or in a compact dashboard layout. They were popularized by Edward Tufte in his 2006 book Beautiful Evidence, which introduced the concept and the name.
A sparkline has no axes, no labels, no legend — just the line itself. The reader infers the shape (going up, going down, spiking, stable) at a glance, and text around the sparkline provides context (the current value, the metric name, the change). The lack of chrome is the point: sparklines are compact enough to embed in paragraphs of text or in tables of metrics.
In matplotlib:
def sparkline(data, ax=None):
if ax is None:
fig, ax = plt.subplots(figsize=(1, 0.3))
ax.plot(data, color="black", linewidth=1)
ax.set_xticks([])
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_visible(False)
return ax
The helper produces a minimal line chart that you can embed in a larger figure. Multiple sparklines arranged in a table or grid give a compact view of many metrics at once.
Sparklines are particularly useful in:
- Dashboards where you want to show many metrics without using large line charts.
- Reports where you want to include a trend indicator inline with text or tables.
- Comparisons where seeing many small charts side by side helps identify outliers.
Plotly does not have a first-class sparkline type, but you can create one by stripping the axes and chrome from a small px.line chart.
25.9 Aspect Ratio and Banking to 45 Degrees
Chapter 8 introduced the concept of banking to 45 degrees — the idea that line charts are most readable when the average slope of the line is close to 45 degrees. At 45 degrees, the reader can compare slopes of adjacent segments most accurately. At much smaller or larger slopes, comparisons become harder.
The implication for time series visualization: the aspect ratio of a time series chart matters a lot. A chart that is too square compresses the x-axis and makes trend changes hard to see. A chart that is too wide stretches the x-axis and makes variation look smaller than it is. The goal is to choose an aspect ratio where the interesting slope changes appear at roughly 45 degrees on average.
William Cleveland's research in the 1980s demonstrated this perceptual effect empirically. Charts with different aspect ratios of the same data lead readers to different conclusions about trend changes. The 45-degree heuristic is a way to avoid inadvertent distortion.
Practical recommendation: for a long time series (years or decades), use a wide aspect ratio (figsize=(12, 4) or (14, 3)). For a short, volatile time series, use a more square aspect ratio. If you are not sure, try several and pick the one where the slopes look natural — not unnaturally steep, not unnaturally flat.
25.10 Interactive Time Series with Plotly
Plotly is particularly good for interactive time series because two Plotly features — the range slider and the unified hover — are tailor-made for time series exploration.
import plotly.express as px
fig = px.line(df, x=df.index, y="value", title="Time Series")
fig.update_layout(
xaxis_rangeslider_visible=True,
hovermode="x unified",
)
fig.show()
The range slider lets the reader drag to zoom into any sub-period; the unified hover shows all traces' values at the hovered date in a single tooltip. For a multi-series chart (temperature AND CO2 AND sea level on the same x-axis), the unified hover is especially valuable — one tooltip shows all three values at the hovered date.
Range selector buttons add preset zoom levels:
fig.update_layout(
xaxis=dict(
rangeselector=dict(
buttons=[
dict(count=1, label="1y", step="year", stepmode="backward"),
dict(count=5, label="5y", step="year", stepmode="backward"),
dict(count=10, label="10y", step="year", stepmode="backward"),
dict(step="all", label="All"),
]
),
rangeslider=dict(visible=True),
)
)
This is the convention financial charts use (Yahoo Finance, Google Finance), and users recognize the interaction pattern. Combined with the range slider, it gives readers overview + zoom + filter in a single compact display.
25.11 Time Series Pitfalls
Time series visualization has a few common pitfalls that deserve explicit mention.
Dual-axis abuse. Putting two time series on the same chart with two different y-axes is tempting because it shows them "together," but it also invites the viewer to see correlations that are artifacts of the chosen scales. Chapter 21 covered this in detail; the pitfall applies especially to time series, where the visual correlation is easy to manufacture with axis scaling.
Non-zero baselines for area charts. Line charts can start the y-axis anywhere, but filled area charts should almost always start at zero. The filled area is interpreted as "magnitude," and a non-zero baseline makes the area meaningless. If you want to emphasize small changes without the zero baseline, use a line chart without fill.
Misleading smoothing. Heavy smoothing (a long rolling mean window) can hide events that matter. A 365-day rolling mean of a daily metric smooths out the COVID dip entirely — if that was an important event, the smoothed chart is misleading. Always disclose the window size and consider showing both raw and smoothed on the same chart.
Gaps and missing data. Missing data in a time series is displayed as a gap (in matplotlib, the line breaks; in Plotly, likewise). This is usually correct behavior — you do not want to interpolate across gaps silently. But readers may not notice the gap, especially in zoomed-out views. Annotate missing-data periods explicitly when they matter.
Irregular sampling. Some time series have unevenly spaced observations (e.g., transaction timestamps). A line chart connecting unevenly-spaced points can look misleading because the visual density of the line varies with the sampling density. For irregular data, consider a scatter plot or a resampled-to-regular-intervals aggregation.
Confusing x-axis zero. Time axes are rarely shown with a "zero" — there is no zero date. Readers sometimes confuse the left edge of a time axis with a meaningful baseline. It is not. The left edge is just the start of your data. If a specific date is meaningful ("baseline," "policy start," "experiment begins"), mark it explicitly with a vertical line or annotation.
Anti-pattern: infinite horizon. A chart that shows "all of history" at one zoom level usually reveals nothing about the recent period, because old data dominates the scale. Use range sliders, log scales, or multiple charts at different zoom levels to avoid this.
25.12 Progressive Project: Comprehensive Climate Time Series
The climate project in this chapter brings together every technique from Sections 25.2–25.10 into a single comprehensive analysis. We will build a 4-panel figure showing:
Panel A: Full series with rolling mean. The raw daily or monthly temperature anomaly with a 10-year rolling mean overlay showing the long-term trend.
Panel B: Seasonal decomposition. The STL decomposition of the last 40 years, showing trend, seasonal, and residual in stacked subplots.
Panel C: Calendar heatmap. A calendar heatmap of the last 30 years of daily temperature anomalies, with color encoding the magnitude and sign of the anomaly.
Panel D: Cycle plot. A seasonal subseries plot showing the monthly mean temperature across decades, with reference lines for each decade's average.
The four panels answer four different questions about the same data:
- "How has temperature changed over the full record?" → Panel A
- "What is the trend underneath seasonal variation?" → Panel B
- "Which specific months and years were unusual?" → Panel C
- "How has the seasonal pattern itself evolved?" → Panel D
No single panel would answer all four questions. Together they provide a complete time series analysis. This is the chapter's main practical lesson: time series often need multiple visualizations at different scales and with different techniques, and the most informative analysis combines several of them.
25.13 Working with Dates in pandas
Before you can visualize a time series, you often need to prepare the data. pandas provides extensive datetime functionality, and the basic operations are worth reviewing because they appear in almost every time series visualization project.
Parsing dates from strings. If your data has a date column stored as strings, convert it to proper datetimes:
df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d")
df = df.set_index("date")
The format argument tells pandas the expected string format. For mixed or ambiguous formats, omit the argument and pandas will try to infer. For ISO-format dates, parsing is unambiguous.
Resampling to a regular frequency. Raw time series data is often irregular. Resampling converts it to a regular frequency by aggregating within each time bucket:
df_daily = df.resample("D").mean() # daily means
df_monthly = df.resample("M").mean() # end-of-month means
df_monthly = df.resample("MS").mean() # start-of-month means
df_yearly = df.resample("Y").sum() # end-of-year sums
df_weekly = df.resample("W").last() # last value of each week
Aggregation functions include mean, sum, min, max, median, count, std, first, last, and custom functions via apply. The frequency codes follow pandas's offset alias conventions: D, W, M, Q, Y, H, min, s, plus MS/QS/YS for start-of-period anchoring.
Filling missing values. If your series has gaps, you can fill them with several strategies:
df["filled"] = df["value"].ffill() # forward fill
df["filled"] = df["value"].bfill() # backward fill
df["filled"] = df["value"].interpolate() # linear interpolation
df["filled"] = df["value"].interpolate(method="time") # time-aware interpolation
df["filled"] = df["value"].fillna(df["value"].mean()) # constant fill
Each method has different implications. Forward fill assumes the value persists until a new observation. Interpolation assumes smooth change between observations. Constant fill is a crude approximation. For visualization, the choice is whether to show gaps (by leaving NaN) or fill them (by computing one of the above). Showing gaps is usually the honest choice; filling is for downstream analysis.
Extracting date components. For analysis and faceting, you often want the year, month, day-of-week, or similar from a datetime column:
df["year"] = df.index.year
df["month"] = df.index.month
df["day_of_week"] = df.index.dayofweek
df["week_of_year"] = df.index.isocalendar().week
df["quarter"] = df.index.quarter
These components become useful columns for grouping (groupby), faceting (sns.catplot(... col="year")), or calendar heatmaps (where you need both "week of year" and "day of week").
Time zones. If your data is tagged with a time zone, pandas handles it explicitly:
df.index = df.index.tz_localize("UTC") # mark as UTC
df.index = df.index.tz_convert("US/Eastern") # convert to another zone
For most visualizations, timezone handling is not a primary concern, but if your data spans multiple zones (international sales, global sensor networks), you need to be explicit to avoid silent bugs.
25.14 Multi-Series Time Series Charts
Many time series visualizations show multiple series on the same chart — temperature AND CO2, revenue by region, stock prices for several companies. The design decisions are: how many series, how to encode them, whether to share a y-axis, and whether to use small multiples instead.
Using color for different series:
fig, ax = plt.subplots(figsize=(12, 5))
for country, group in df.groupby("country"):
ax.plot(group.index, group["gdp"], label=country, linewidth=1.5)
ax.legend(title="Country")
Works for up to about 6-8 series. Beyond that, colors become indistinguishable and legend tracking becomes hard.
Small multiples for many series:
g = sns.FacetGrid(df, col="country", col_wrap=4, height=2.5, aspect=1.5, sharey=False)
g.map_dataframe(sns.lineplot, x="date", y="gdp")
Small multiples scale to 20+ series by giving each its own panel. The trade-off is that cross-series comparison is harder when they are in separate panels.
Stacked area for part-to-whole:
fig, ax = plt.subplots(figsize=(12, 5))
ax.stackplot(df.index, df_pivot["solar"], df_pivot["wind"], df_pivot["nuclear"],
labels=["Solar", "Wind", "Nuclear"], alpha=0.7)
ax.legend(loc="upper left")
Stacked area works when the series sum to a meaningful whole (energy mix, budget categories). Do not use it when the series are independent — stacking implies a relationship that does not exist.
Grayed-out background with highlight:
for country, group in df.groupby("country"):
color = "red" if country == "Japan" else "lightgray"
lw = 2 if country == "Japan" else 1
ax.plot(group.index, group["gdp"], color=color, linewidth=lw)
When one series is the story and the others are context, fade the others to gray. The reader's eye goes to the highlighted line immediately, and the other lines provide comparison context without competing for attention.
These patterns compose. A common multi-series chart is: small multiples with one highlighted series per panel and others grayed-out. This communicates both the individual trajectories and the comparison context without overwhelming the viewer.
25.15 Log Scales for Time Series
Time series that span many orders of magnitude benefit from log scales on the y-axis. Stock prices that grow from $1 to $1000 look dramatic on a linear scale — most of the movement is at the top — but on a log scale, each doubling appears as the same vertical distance. This is usually more informative.
fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(df.index, df["price"])
ax.set_yscale("log")
ax.set_title("Stock Price (log scale)")
The log scale reveals constant percentage changes as straight lines — a very useful property for growth data. Returns, compound growth, exponential decay, and bacterial populations all look more natural on log scales.
A specific use of log scales for time series: during the early COVID-19 pandemic, the NYT, Financial Times, and many other outlets plotted cases on log scales to show exponential growth. On a linear scale, the early cases were nearly invisible because they were dwarfed by later cases. On a log scale, the exponential doubling was immediately apparent. The log scale made the growth rate comparable across countries at different stages of their outbreak.
When not to use a log scale: for data that goes negative (log of a negative number is undefined), for data that has a meaningful zero (percent change, departures from baseline), and for audiences unfamiliar with log scales (who may misread the axis). For general audiences, a log scale requires explanation; for technical audiences, it is often the default.
25.16 Real-Time and Streaming Time Series
Not all time series are historical. Some arrive continuously — sensor readings, stock quotes, server metrics, IoT data — and the visualization must update as new data arrives. This is real-time or streaming visualization, and it requires different tools from static historical charts.
The main options in Python:
matplotlib animation (Chapter 15): FuncAnimation can redraw a chart at fixed intervals. Simple for prototyping but limited for production use.
Plotly Dash (Chapter 30): uses callbacks triggered by dcc.Interval components to refresh the chart on a schedule. Scales to multiple users and multiple metrics.
Streamlit with st.empty() and loops: a simpler alternative to Dash for dashboards that need real-time updates.
Grafana: a dedicated open-source monitoring dashboard tool built for real-time metrics. Not Python-native but widely used in production environments where Python sits on the data-collection side and Grafana handles the visualization.
Bokeh streaming: Bokeh (Plotly's main open-source competitor for interactive viz) has a streaming API that is designed for real-time data and can be more efficient than redrawing the full chart on every update.
For most data science use cases, you do not need true real-time visualization — updating a static dashboard once a minute or once an hour is fine. Real-time (updates per second or faster) is needed only for operational monitoring, trading systems, and similar latency-sensitive applications. When in doubt, stick with periodic refresh and avoid the complexity of a true streaming pipeline.
25.17 Forecasts and Confidence Intervals
A historical time series tells you what happened. A forecast projects it forward to show what might happen. Visualizing forecasts requires conveying two things simultaneously: the central prediction and the uncertainty around it. Without the uncertainty, a forecast chart implies precision that the underlying model cannot deliver.
The standard visualization is a line for the central forecast and a shaded band for the confidence interval:
fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(df_hist.index, df_hist["value"], color="black", label="Historical")
ax.plot(df_fcst.index, df_fcst["mean"], color="red", label="Forecast")
ax.fill_between(df_fcst.index, df_fcst["lower"], df_fcst["upper"],
color="red", alpha=0.2, label="80% interval")
ax.axvline(df_fcst.index[0], color="gray", linestyle="--")
ax.legend()
The vertical line at the boundary between history and forecast makes the transition explicit. The shaded band widens as the forecast extends further into the future, visually encoding that uncertainty grows with horizon. Readers get both the "best estimate" and the "how confident we are" at a single glance.
Several forecast-specific design decisions:
Multiple confidence bands. Some forecasts include nested bands (50%, 80%, 95%) for a fuller picture of uncertainty. Use increasingly transparent alpha for wider bands: alpha=0.5 for 50%, alpha=0.3 for 80%, alpha=0.15 for 95%. The result is a "fan chart" that conveys the full uncertainty distribution.
Color contrast. Use a distinct color for the forecast (red, orange) that is visually different from the historical series (black, blue). This helps the reader mentally separate "what we know" from "what we predict."
Point forecasts vs. distributional forecasts. If your forecast is a distribution (Bayesian posterior, ensemble mean), the band represents the distribution's quantiles directly. If it is a point estimate with error bars (classical regression), the band represents the prediction interval. The distinction matters — the former is conditional on the model; the latter includes observational noise.
Model comparison. When multiple models produce different forecasts, show them side by side or as overlaid lines with distinct colors. The visual comparison is usually more informative than a table of forecast errors.
Forecast visualization is central to business planning, weather prediction, climate science, and epidemiology. The design principles — show uncertainty, make the history/future boundary obvious, pick contrasting colors — are the same across all of them.
25.18 A Note on Data Frequency Mismatches
Real datasets often mix frequencies: daily observations joined with monthly aggregates, weekly totals merged with annual benchmarks, hourly sensor readings compared to daily averages. Visualizing these mixed-frequency series requires thought.
The common approaches:
Resample the higher-frequency series to match. If you have daily data and monthly benchmarks, aggregate the daily data to monthly before plotting. The result is a clean single-frequency chart. The cost is that you lose the daily detail.
Plot both frequencies on the same chart with visual distinction. Daily data as a thin gray line; monthly aggregates as markers or thick colored segments. The reader sees both scales, though the chart becomes busier.
Use separate panels for different frequencies. Top panel daily, bottom panel monthly, shared x-axis. Each panel is clean; comparison requires the reader to look between panels.
Highlight periodic aggregates on the raw series. A daily line with monthly markers at the end of each month showing the monthly sum or mean. The markers emphasize the periodic structure without crowding the raw line.
The right choice depends on the analysis. For exploration, keep both frequencies visible. For reporting, pick the frequency that matches the reader's question — monthly for "how did we do this month," daily for "what happened on March 15."
Avoid one specific anti-pattern: plotting daily data and weekly aggregates on the same line chart with the same styling. The reader cannot tell which points are daily and which are weekly, and the visual implies they are comparable when they are not. Always distinguish the two visually.
25.19 Event Markers and Context Lines
A time series chart often needs to reference events that happened at specific moments: a product launch, a policy change, a natural disaster, a quarterly earnings report. These events may or may not cause a visible change in the series, but they are part of the context the reader needs to interpret the chart correctly.
The tools for marking events:
Vertical lines (axvline): for instantaneous events. A policy implementation date, a single earthquake, a product launch. The line marks the exact moment.
Vertical spans (axvspan): for events with duration. A war, a recession, a lockdown period, an A/B test phase. The shaded region shows the event's start and end.
Annotations (annotate): for labeled events. A name or description attached to a line or span, often with an arrow pointing to the specific date.
Event dots on the x-axis: for many small events. Instead of lines across the whole chart, place small markers on the x-axis at each event date. Hover (Plotly) or legend-style tooltips show details.
A well-annotated time series tells a story: the reader sees not just the data but the context that explains it. Why did sales drop in March 2020? The annotation says "COVID-19 lockdown begins." Why did temperature anomalies jump in 1998? The annotation says "Strong El Niño." Annotations turn raw charts into narratives.
Event annotations should be chosen sparingly. A chart with 20 vertical lines is visually cluttered and each line loses its emphasis. Aim for 2–5 key events on any single chart. If more events are relevant, consider breaking the chart into multiple panels, each with its own events, or using hover/interactive tooltips to show details on demand.
The discipline of choosing which events to annotate is itself an editorial decision — you are telling the reader what to pay attention to. Make the choice deliberately and include a note about the source of event dates so the annotations are verifiable. An annotation that cannot be traced to a specific source is weaker than one that can.
A practical tip: keep a small "events" DataFrame alongside your time series data, with columns for date, label, and category. When you build a chart, you can iterate over this DataFrame and add the appropriate annotations programmatically. This makes the annotations reproducible across multiple charts (the same events appear consistently in any chart of the same data) and makes it easy to add, remove, or edit events without rewriting chart code.
events = pd.DataFrame({
"date": pd.to_datetime(["2020-03-15", "2020-11-09", "2021-07-01"]),
"label": ["Lockdown begins", "Vaccine announced", "Reopening"],
"category": ["policy", "science", "policy"],
})
for _, row in events.iterrows():
ax.axvline(row["date"], linestyle="--", alpha=0.5, color="gray")
ax.annotate(row["label"], xy=(row["date"], ax.get_ylim()[1]),
xytext=(5, -15), textcoords="offset points",
ha="left", fontsize=8, rotation=0)
The pattern scales to many events and many charts. You maintain one canonical events list and reuse it everywhere, and you can filter by category when a chart needs only a subset (e.g., only "policy" events for a policy-focused chart, only "science" events for a scientific progress chart). The same principle applies to band markers (axvspan) for events with duration — store start_date and end_date as additional columns and iterate the same way. This small data-engineering discipline pays off repeatedly as your chart library grows, because you stop re-copying event dates into new chart files and start referencing a single source of truth. It is a small habit that separates ad-hoc analysis from reproducible reporting, and it fits the broader philosophy of this book: every visualization decision, once made, should be codified so the next version of the chart inherits the thinking behind the first one.
25.20 Check Your Understanding
Before continuing to Chapter 26 (Text and NLP Visualization), make sure you can answer:
- What does matplotlib.dates.DateFormatter do, and what strftime codes are common for time series axes?
- What is the difference between a rolling mean and an exponential moving average?
- What are the three components of a classical seasonal decomposition?
- When would you use a calendar heatmap instead of a line chart?
- What is a cycle plot, and what question does it answer?
- What are sparklines, who invented them, and when should you use them?
- What does "banking to 45 degrees" mean for time series charts?
- Name three time series pitfalls and their remedies.
If any of these are unclear, re-read the relevant section. Chapter 26 leaves numeric time series for the specialized domain of text and NLP visualization.
25.21 Chapter Summary
This chapter covered the specialized techniques for visualizing time series data:
- Datetime axes require explicit locator and formatter configuration for readable tick density and label format.
- Rolling means smooth noisy data to reveal trend. Window size is a design choice; EMA is a weighted alternative.
- Seasonal decomposition (via statsmodels'
seasonal_decomposeorSTL) separates a series into trend + seasonal + residual components. - Anomalies and change points are highlighted with scatter overlays, fill_between regions, vertical lines, and shaded spans.
- Calendar heatmaps (via calplot or manual matplotlib) compress daily data into compact 2D views showing seasonal and weekly patterns.
- Cycle plots arrange seasonal subseries side by side for direct comparison across years.
- Sparklines are very small line charts suitable for inline and dashboard use.
- Aspect ratio and banking affect the perception of slope and trend — wide charts are usually better for long time series.
- Interactive time series in Plotly combine range sliders, range selector buttons, and unified hover for rich exploration.
- Time series pitfalls include dual-axis abuse, non-zero baselines for area charts, misleading smoothing, invisible missing data, and confusing x-axis zero.
No new threshold concept — this chapter applied general visualization principles to the specific case of temporal data. The payoff is a practical toolkit: given a time series, you now have the vocabulary and the tools to choose the right visualization for the question.
Chapter 26 introduces text and NLP visualization — word clouds, topic models, sentiment over time, co-occurrence networks. It continues the specialized-domain theme of Part VI, applying visualization techniques to a data type (text) that has its own conventions and pitfalls.
25.22 Spaced Review
- From Chapter 11 (Essential Chart Types): A line chart is the default for time series. What other chart types might be appropriate?
- From Chapter 14 (Specialized matplotlib Charts): Calendar heatmaps are a specialized case of the 2D heatmap covered in Chapter 14. What adaptations does the calendar context require?
- From Chapter 9 (Storytelling): Anomaly annotations are a form of direct labeling. How does this connect to Chapter 9's discussion of narrative structure?
- From Chapter 20 (Plotly Express): The range slider was introduced in Chapter 20 for general interactive charts. Why is it particularly valuable for time series?
- From Chapter 4 (Honest Charts): The time series pitfalls discussed in Section 25.11 are specific forms of the general lie-factor problem from Chapter 4. What are the parallels?
Time series is one of the most common forms of data in every field, from finance to science to business to public health. Time series are the bread and butter of data visualization. Nearly every business dashboard, every scientific paper with longitudinal data, every climate report, and every financial chart is a time series visualization of some kind. The techniques in this chapter — datetime axes, rolling means, decomposition, calendar heatmaps, sparklines — are the vocabulary for these charts. Master them, and you can visualize nearly any temporal data effectively. Chapter 26 continues Part VI with the specialized domain of text visualization.