Data visualisation and storytelling · Beginner
Line chart best practices for time series data
Why it matters
"How are we trending?" is the question managers ask most, so line chart best practices matter in nearly every analyst job: visualisation appears in 94% of the Data Analyst job descriptions we read, and trend reporting is the core of most of them. A line chart looks simple, which is why its mistakes (a smoothed line that hides an event, a partial month that fakes a crash, twenty overlapping lines) slip into decks unnoticed.
The concept
Time on the x-axis must be real time
A line implies continuity between points, and the horizontal distance between points is read as elapsed time. If you plot dates as text labels, matplotlib, Excel and most BI tools space them evenly, so a missing month vanishes and October sits next to December as if November never happened. Always plot a real date type, and reindex to a complete calendar so gaps are visible. If data is truly missing (not zero), leave a break in the line rather than drawing a zero.
The y-axis need not start at zero, but say so
Lines encode position, not length, so zooming the y-axis is allowed when the change is the point. A share price between 1,480 and 1,520 drawn from zero is a flat line. Two rules keep a zoom honest: label the axis clearly so the reader sees the range, and do not zoom so far that noise looks like a trend. If the absolute level matters (trips fell to zero during a strike), keep zero.
Aspect ratio and banking to 45 degrees
The same data drawn tall and narrow looks dramatic; drawn wide and flat it looks calm. William Cleveland's guideline, banking to 45 degrees, picks an aspect ratio where the typical line segment sits near 45 degrees, where slopes are judged most accurately. In practice: daily data over months wants a wide chart (about 3:1 or 4:1); a short series of yearly values can be closer to square.
Too many lines: highlight, grey or small multiples
Five lines are the practical limit for one chart. Beyond that, choose one of:
| approach | when |
|---|---|
| highlight one, grey the rest | one series is the story and the rest are context |
| small multiples | every series matters on its own (covered later in this skill) |
| aggregate | the reader needs the total, not the parts |
Label lines directly at their right end instead of using a legend. The reader's eye is already there.
Smoothing: moving averages, and label the window
Daily data has weekly cycles: taxi trips, food delivery orders and UPI payments all swing by weekday. A 7-day moving average removes that cycle exactly, because every window holds one of each weekday. Rules:
Say the window and whether it is trailing (pandas
rolling(7)is trailing: today plus the 6 days before) or centred (rolling(7, center=True)). A trailing average lags turning points by about 3 days.Keep the raw series as a thin grey line behind the average, so outliers and events stay visible.
The first 6 values of a 7-day trailing average are undefined. Do not fill them with partial averages without saying so.
Partial last periods
Data loaded on the 9th of the month makes the current month look like a collapse. Options, best first: drop the partial period, show it dotted or hollow with a note ("1 to 9 Dec"), or switch to a per-day rate. Weekly series have the same trap at both ends.
Indexing to 100 to compare growth
Manhattan has 6.2 million yellow taxi pickups in two months and the Bronx has 36,207. On one axis the Bronx is a flat line on the floor. Divide each series by its own value at a base period and multiply by 100: every line starts at 100, and the chart shows growth, not size. Choose a base that is typical (an average week, not a holiday) and state it on the axis. Then remind the reader of the absolute sizes, because 100% growth on a tiny base can be a few hundred trips.
Annotate events
Policy changes, launches, outages, festivals, strikes, data pipeline breaks. A vertical line and a short label at the event turn a wiggle into an explanation. Annotate only what matters to the question; a line with ten annotations is a timeline, not a chart.
Worked example: daily taxi trips and congestion pricing
New York started congestion pricing for vehicles entering Manhattan below 60th Street on 5 January 2025. The data is the NYC Taxi and Limousine Commission (TLC) yellow taxi trip records, published as open data (TLC trip record data). Download yellow_tripdata_2025-01.parquet and yellow_tripdata_2025-02.parquet (about 60 MB each, 3,475,226 and 3,577,543 trips) and taxi_zone_lookup.csv from that page. The outputs below cover 1 January to 28 February 2025; add yellow_tripdata_2024-12.parquet to the list to see the weeks before the policy.
import duckdb
import matplotlib.pyplot as plt
import pandas as pd
daily = duckdb.sql("""
select cast(t.tpep_pickup_datetime as date) as day, z.Borough as borough, count(*) as trips
from read_parquet(['yellow_tripdata_2025-01.parquet', 'yellow_tripdata_2025-02.parquet']) t
join 'taxi_zone_lookup.csv' z on z.LocationID = t.PULocationID
where t.tpep_pickup_datetime >= '2025-01-01' and t.tpep_pickup_datetime < '2025-03-01'
group by all
""").df()
wide = daily.pivot(index="day", columns="borough", values="trips").fillna(0)
wide.index = pd.to_datetime(wide.index)
total = wide.sum(axis=1)
ma7 = total.rolling(7).mean() # trailing window: first 6 days are NaN
print(pd.DataFrame({"trips": total, "ma7": ma7.round(0)}).head(9).to_string())
print("weekday means:", total.groupby(total.index.day_name()).mean().round(0).sort_values().to_dict())
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(total.index, total / 1e3, color="#bbb", lw=1, label="daily")
ax.plot(ma7.index, ma7 / 1e3, color="#1f4e79", lw=2.2, label="7-day average (trailing)")
ax.axvline(pd.Timestamp("2025-01-05"), color="#c0504d", lw=1, ls="--")
ax.annotate("5 Jan: congestion pricing starts\nin Manhattan below 60th St",
xy=(pd.Timestamp("2025-01-05"), 60), xytext=(10, 0), textcoords="offset points",
fontsize=8, color="#c0504d")
ax.set_ylim(0, None)
ax.set_ylabel("yellow taxi trips per day (thousand)")
ax.set_title("Yellow taxi trips, January and February 2025", loc="left")
ax.spines[["top", "right"]].set_visible(False)
ax.legend(frameon=False, loc="lower right")
fig.tight_layout()
fig.savefig("daily_trips.png", dpi=150)
wide.to_csv("daily_by_borough.csv") trips ma7
day
2025-01-01 90188 NaN
2025-01-02 84832 NaN
2025-01-03 91250 NaN
2025-01-04 97804 NaN
2025-01-05 79624 NaN
2025-01-06 80126 NaN
2025-01-07 100643 89210.0
2025-01-08 113897 92597.0
2025-01-09 119285 97518.0
weekday means: {'Monday': 95342.0, 'Sunday': 109046.0, 'Tuesday': 116565.0, 'Wednesday': 120813.0, 'Friday': 129667.0, 'Thursday': 129930.0, 'Saturday': 132679.0}What to notice:
The trailing average starts on 7 January. The first six rows are
NaN, which is correct: there is no 7-day window yet. The chart simply starts the blue line later.Weekdays differ by 39%. Monday averages 95,342 trips and Saturday 132,679 (two Mondays in the window were public holidays: 20 January and 17 February). The grey daily line zigzags with that cycle; the 7-day average removes it cleanly because each window contains every weekday once.
The first week is low because of the New Year holiday period, not because of congestion pricing. The vertical line marks the event, but nothing in this chart proves cause. To say anything about the policy you need the pre-period (December, and the same weeks of 2024) and a comparison group. The annotation tells the reader when; it does not claim why.
The y-axis starts at zero here because the level matters (how many trips) and the swings are large enough to see anyway.
Worked example: indexing boroughs to 100
Plotting four boroughs on one trips axis puts the Bronx and Brooklyn on the floor. Index them instead, highlight one, and grey the rest:
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
wide = pd.read_csv("daily_by_borough.csv", index_col="day", parse_dates=True)
boroughs = ["Manhattan", "Queens", "Brooklyn", "Bronx"]
print("trips per borough:", wide[boroughs].sum().astype(int).to_dict())
ma7 = wide[boroughs].rolling(7).mean()
base = ma7.loc["2025-01-12"] # average of the week 6 to 12 January
index = ma7 / base * 100
print(index.loc[["2025-01-12", "2025-01-26", "2025-02-09", "2025-02-28"]].round(1).to_string())
fig, ax = plt.subplots(figsize=(9, 4))
for b in boroughs:
highlight = b == "Bronx"
ax.plot(index.index, index[b], lw=2.4 if highlight else 1.2,
color="#1f4e79" if highlight else "#aaa")
ax.text(index.index[-1], index[b].iloc[-1], f" {b}", va="center", fontsize=8,
color="#1f4e79" if highlight else "#777")
ax.axhline(100, color="#333", lw=0.6)
ax.set_ylabel("7-day average, week of 6 to 12 Jan = 100")
ax.set_title("Bronx and Brooklyn pickups doubled from a small base; Manhattan rose 16%", loc="left", fontsize=10)
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
fig.savefig("borough_index.png", dpi=150)
# Weekly totals: the last week is partial
weekly = wide.sum(axis=1).resample("W-SUN").agg(["sum", "count"])
print(weekly.tail(3).to_string())
# Banking: median absolute angle of the line segments, as drawn, at two aspect ratios
total = wide.sum(axis=1).rolling(7).mean().dropna()
for size in [(4, 4), (12, 3)]:
fig, ax = plt.subplots(figsize=size)
line, = ax.plot(total.index, total.values)
fig.canvas.draw()
xy = ax.transData.transform(np.column_stack([line.get_xdata(orig=False), line.get_ydata()]))
dx, dy = np.diff(xy[:, 0]), np.diff(xy[:, 1])
print(size, "median segment angle:", round(np.median(np.degrees(np.abs(np.arctan2(dy, dx)))), 1), "degrees")trips per borough: {'Manhattan': 6248129, 'Queens': 586118, 'Brooklyn': 162885, 'Bronx': 36207}
Manhattan Queens Brooklyn Bronx
day
2025-01-12 100.0 100.0 100.0 100.0
2025-01-26 112.2 106.2 147.3 132.2
2025-02-09 117.8 101.6 172.2 169.5
2025-02-28 116.0 119.6 207.1 234.6
sum count
day
2025-02-16 926549 7
2025-02-23 862666 7
2025-03-02 635633 5
(4, 4) median segment angle: 50.9 degrees
(12, 3) median segment angle: 17.1 degreesReading the output:
Index base. Each borough's 7-day average on 12 January (the week of 6 to 12 January, the first normal working week of the year) is 100. By 28 February the Bronx is at 234.6 and Brooklyn at 207.1, while Manhattan is at 116.0 and Queens at 119.6.
Size check. The Bronx had 36,207 yellow taxi pickups in the two months, against 6,248,129 in Manhattan. The doubling is real but small in absolute trips. The title says "from a small base" so nobody mistakes growth for size.
Highlight one, grey the rest. The story is the Bronx line; the others are context, labelled at their right end, with no legend. Where two labels collide (Queens and Manhattan end within 4 points), nudge one or move it into the plot.
The partial week.
resample("W-SUN")makes the week ending 2 March from only 5 days of data (count= 5), so its total of 635,633 looks like a 26% drop from the week before. That week should be dropped or marked, never plotted as is.Banking. The same 7-day average drawn in a 4 x 4 inch box has a median segment angle of 50.9 degrees; in a 12 x 3 box it is 17.1 degrees. The square version is closer to 45 degrees and shows wiggles more; the wide one looks calmer. Neither is "the truth": choose deliberately, and use the same aspect ratio when you compare charts.
Worked example: uneven dates on a text axis
import pandas as pd
orders = pd.read_csv("olist_orders_dataset.csv", parse_dates=["order_purchase_timestamp"])
monthly = orders.groupby(orders["order_purchase_timestamp"].dt.to_period("M")).size()
print(monthly.head(4).to_string())
# Strings on the x-axis: matplotlib spaces them evenly, so Oct 2016 sits next to Dec 2016
as_text = monthly.copy()
as_text.index = as_text.index.astype(str)
# A real time axis: reindex to every month so the gap becomes visible (and honest)
full = monthly.reindex(pd.period_range(monthly.index.min(), monthly.index.max(), freq="M"), fill_value=0)
print(len(as_text), "points as text vs", len(full), "months on a real calendar")
print(full.head(5).to_string())order_purchase_timestamp
2016-09 4
2016-10 324
2016-12 1
2017-01 800
Freq: M
25 points as text vs 26 months on a real calendar
2016-09 4
2016-10 324
2016-11 0
2016-12 1
2017-01 800
Freq: MOlist has no orders in November 2016. Plotted with text labels, 25 points are spaced evenly and the gap disappears. Reindexed to a real calendar there are 26 months and November shows as zero. Here zero is true (the marketplace data has no orders that month); if the month were missing because of a data load failure, you would leave it as NaN so the line breaks instead.
Common mistakes
Dates as strings. Missing periods vanish and irregular gaps look regular. Convert to datetime and reindex to a full calendar.
An unlabelled smoothing window. "Smoothed trips" could mean 3, 7 or 30 days, trailing or centred. Put "7-day trailing average" in the legend or title.
Smoothing away the event. A 30-day average spreads a one-day outage over a month. Keep the raw series visible behind it.
Plotting the partial current period. It always looks like a drop. Drop it, mark it, or show per-day rates.
Twenty coloured lines. Nobody can match twenty colours to a legend. Highlight one, or use small multiples.
Comparing growth on raw counts. Big series dominate the axis. Index to 100, and state the base and the absolute sizes.
An index base on an unusual day. Indexing to 1 January makes every later day look like growth. Use a typical week's average.
Annotations that claim cause. "Trips fell because of the new policy" needs evidence; "Policy starts" does not.
Interview angle
How would you show a trend for 20 product lines without a spaghetti chart?
First ask what the reader needs. If one or two lines are the story, I highlight them in colour and draw the other 18 in light grey for context, with direct labels at the line ends. If every product matters, I use small multiples with a shared y-axis, sorted by size or growth. If they compare growth across very different sizes, I index each line to 100 at a typical base period. I avoid 20 colours and a legend.
Your monthly revenue chart shows a big drop in the latest month. What do you check?
Whether the month is complete. Data loaded mid-month always shows a drop. I would check the last date in the data and the number of trading days per month, then either drop the partial month, draw it dotted with a note, or plot revenue per trading day. After that I would check for pipeline issues, such as a source that stopped loading.
When should a line chart's y-axis start at zero?
When the absolute level matters or when ratios between points will be read, for example "trips halved". When the change is the message and the values sit far from zero, a zoomed axis is fine for a line, because lines encode position, not length. I label the axis so the zoom is visible, and I never zoom a bar chart.
Practice task
Download the two yellow taxi files and the zone lookup, and run
daily_trips.py. Confirm your first 9 rows match the output above.Add
yellow_tripdata_2024-12.parquetand redraw the daily chart from 1 December. Mark Christmas and New Year's Day as well as 5 January. Write two sentences on what you can and cannot conclude about congestion pricing from this chart alone.Redraw
borough_index.pywith a centred 7-day average. How many days does the lag shift the Bronx's mid-January rise?Plot the share of trips that paid the congestion fee (
cbd_congestion_fee > 0) per day. Decide whether this y-axis should start at zero.Done when: every chart has a real date axis, a labelled smoothing window, the partial week handled, and one annotation that states an event without claiming its effect.
Hint for step 4
In the DuckDB query add avg(case when cbd_congestion_fee > 0 then 1 else 0 end) as share_paid grouped by day. The share is a proportion of a whole, so start at zero and end at 100%; a zoomed axis would make small daily wobbles look like policy effects.
Further reading
NYC TLC trip record data: monthly files, the data dictionary and the zone lookup.
pandas rolling windows: trailing, centred and time-based windows, and
min_periods.matplotlib dates and time axes: date locators and formatters.