Telling a Story with Charts
The charts you make while exploring are for you: quick, rough and many. The charts you show to a shop owner, a principal or a manager are different. They must make one point so clearly that a busy person gets it in five seconds, and they must be honest.
In this lesson you will take a plain default chart and turn it, step by step, into an explanatory chart that tells a story: a title that states the finding, sorted and highlighted bars, annotations, less clutter and sensible colours.
Exploratory vs explanatory charts
| Exploratory | Explanatory | |
|---|---|---|
| Audience | You | Someone who must understand or decide |
| Number | Dozens | One to three |
| Title | Describes the data ("Revenue by month") | States the finding ("Revenue doubled in the festive season") |
| Colour | Defaults | Grey for context, one colour for the point |
| Labels | Whatever matplotlib gives | Only what supports the message |
Before you draw an explanatory chart, write the message as one sentence. If you cannot, you are not ready to present the chart yet.
Step 1: the default chart
Here is the question: "In which months should the shop plan extra stock and staff?" Start with a default bar chart of monthly revenue.
import pandas as pd
import matplotlib.pyplot as plt
sales = pd.read_csv("sales.csv", parse_dates=["order_date"]).drop_duplicates()
sales["revenue"] = sales["quantity"] * sales["unit_price"] * (1 - sales["discount"] / 100)
monthly = sales.groupby(sales["order_date"].dt.month)["revenue"].sum() / 100000
plt.bar(monthly.index, monthly.values)
plt.title("Revenue by month")
plt.show()
print(monthly.round(2))
print("Oct + Nov share of the year:", round((monthly[10] + monthly[11]) / monthly.sum() * 100, 1), "%")order_date 1 1.13 2 1.17 3 1.48 4 1.14 5 1.10 6 0.95 7 1.68 8 1.61 9 1.93 10 2.81 11 2.75 12 1.32 Name: revenue, dtype: float64 Oct + Nov share of the year: 29.1 %
The chart has twelve blue bars of similar height, with October and November clearly taller. It is correct, but the reader must do all the work: find the tall bars, work out the months from numbers 1 to 12, guess the unit, and decide what it means. The printout gives the key fact: October and November together bring in about 29% of the whole year's revenue.
Step 2: the message in the title, highlight in colour
Make the finding the title, label the axis with its unit, use month names, and colour only the bars that matter. Everything else becomes grey context.
import pandas as pd
import matplotlib.pyplot as plt
sales = pd.read_csv("sales.csv", parse_dates=["order_date"]).drop_duplicates()
sales["revenue"] = sales["quantity"] * sales["unit_price"] * (1 - sales["discount"] / 100)
monthly = sales.groupby(sales["order_date"].dt.month)["revenue"].sum() / 100000
names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
colours = ["#c2410c" if m in (10, 11) else "#cbd5e1" for m in monthly.index]
fig, ax = plt.subplots(figsize=(9, 4))
ax.bar(names, monthly.values, color=colours)
ax.set_title("October and November bring in 29% of the year's revenue", loc="left", fontsize=13)
ax.set_ylabel("Revenue (₹ lakh)")
for side in ["top", "right"]:
ax.spines[side].set_visible(False)
plt.show()Now only two bars are orange; the other ten are light grey. The eye goes straight to October and November, and the left-aligned title tells the reader what to notice before they even look at the bars. Nothing about the data changed; only the design did.
Step 3: annotate the key point
An annotation is text placed on the chart, often with an arrow, that explains a specific point. Use one or two, not ten. ax.annotate() takes the text, the point to mark (xy) and the text position (xytext).
import pandas as pd
import matplotlib.pyplot as plt
sales = pd.read_csv("sales.csv", parse_dates=["order_date"]).drop_duplicates()
sales["revenue"] = sales["quantity"] * sales["unit_price"] * (1 - sales["discount"] / 100)
daily = sales.groupby("order_date")["revenue"].sum()
smooth = daily.rolling(14, center=True, min_periods=7).mean() / 1000
peak_day = smooth.idxmax()
fig, ax = plt.subplots(figsize=(9, 4))
ax.plot(smooth.index, smooth.values, color="#c2410c", linewidth=2)
ax.axvspan(pd.Timestamp("2025-10-01"), pd.Timestamp("2025-11-15"), color="#c2410c", alpha=0.08)
ax.annotate("Diwali, 20 Oct", xy=(pd.Timestamp("2025-10-20"), smooth.loc["2025-10-20"]),
xytext=(pd.Timestamp("2025-06-15"), smooth.max() * 0.95),
arrowprops={"arrowstyle": "->", "color": "gray"})
ax.set_title("Daily revenue climbs through the festive season (14-day average)", loc="left")
ax.set_ylabel("₹ thousand per day")
for side in ["top", "right"]:
ax.spines[side].set_visible(False)
plt.show()
print("Peak of the 14-day average:", peak_day.date(), round(smooth.max(), 1), "thousand per day")Peak of the 14-day average: 2025-11-05 12.8 thousand per day
The line runs low and fairly flat for most of the year, rises in September and reaches its highest level in the shaded festive band from October to mid-November, then falls away in December. A grey arrow points from the label "Diwali, 20 Oct" to the line. The shaded band (axvspan) marks the season, and the annotation connects the pattern to a cause the reader already knows. Diwali fell on 20 October in 2025; check such dates for the year you are analysing.
Step 4: honesty checks
A beautiful chart can still mislead. Before sharing, run through these checks:
- Bar charts start at zero. Cutting the axis at, say, ₹9 lakh makes a 10% difference look like 300%.
- Compare like with like. Totals favour big groups. Compare Pune with Delhi using revenue per order or per customer if their sizes differ.
- Show uncertainty where it matters. A difference between two small groups may be luck. The statistics lessons, especially Hypothesis Testing Basics, show how to check.
- Do not cherry-pick the time window. Starting a line chart at the lowest month exaggerates growth.
- One y-axis per chart. Two scales on one chart let you make any two lines appear to move together.
import matplotlib.pyplot as plt
cities = ["Pune", "Delhi"]
revenue = [9.6, 10.4] # ₹ lakh
fig, axes = plt.subplots(1, 2, figsize=(9, 3.5), layout="constrained")
axes[0].bar(cities, revenue, color="#94a3b8")
axes[0].set_ylim(9.5, 10.5)
axes[0].set_title("Misleading: axis starts at 9.5")
axes[1].bar(cities, revenue, color="#94a3b8")
axes[1].set_ylim(0, 11)
axes[1].set_title("Honest: axis starts at 0")
plt.show()
print(f"Real difference: {(revenue[1] / revenue[0] - 1) * 100:.1f}%")Real difference: 8.3%
On the left, Delhi's bar looks about nine times taller than Pune's, because the axis starts at 9.5. On the right, with the axis from zero, the bars look almost the same, which matches the real difference of about 8%.
Accessible colour choices
About 1 in 12 men worldwide has some form of colour vision deficiency, most often difficulty telling red from green. Design for them:
- Do not rely on red versus green alone to mean bad versus good. Add labels, icons or position.
- Use one highlight colour against grey, as in step 2. It works for almost everyone.
- For several categories, use a colour-blind-friendly set such as matplotlib's
tableau-colorblind10style, and keep each category's colour the same across all charts in a report. - For values from low to high, use one hue from light to dark (
"Blues","viridis"), never a rainbow. - Make text dark enough to read and large enough for slides (at least 11 to 12 points).
Common mistakes
- Showing every exploratory chart. Choose the one to three charts that support the decision.
- Descriptive titles. "Revenue by month" describes; "Revenue doubled in October" informs.
- Rainbow colours for no reason. Colour should mean something: the highlight, a category, or a value scale.
- Labelling every point. Label the one or two that matter; the axis handles the rest.
Key points
- Decide the message first and write it as the chart title.
- Use grey for context and one strong colour for the point you want noticed.
- Annotate only the key moments with
ax.annotate()and shaded spans such asax.axvspan(). - Keep charts honest: zero-based bars, fair comparisons, no cherry-picked windows, one y-axis.
- Choose colours that work for colour-blind readers and never rely on red versus green alone.
Exercise
Your message is: "Kochi gets more rain than any other city." Using weather.csv, compute the total 2025 rainfall per city, and draw a sorted horizontal bar chart where Kochi's bar is highlighted and the others are grey. Put the message in the title, add the unit to the axis label, and write each bar's total at the end of the bar with ax.bar_label().
Show solution
import pandas as pd
import matplotlib.pyplot as plt
weather = pd.read_csv("weather.csv")
rain = weather.groupby("city")["rainfall_mm"].sum().sort_values()
colours = ["#0369a1" if c == "Kochi" else "#cbd5e1" for c in rain.index]
fig, ax = plt.subplots(figsize=(7, 3.5))
bars = ax.barh(rain.index, rain.values, color=colours)
ax.bar_label(bars, fmt="%.0f mm", padding=3)
ax.set_title("Kochi gets more rain than any other city", loc="left")
ax.set_xlabel("Total rainfall in 2025 (mm)")
for side in ["top", "right"]:
ax.spines[side].set_visible(False)
plt.show()
print(rain.round(0))city Jaipur 436.0 Delhi 1046.0 Chennai 1320.0 Mumbai 1766.0 Kochi 2956.0 Name: rainfall_mm, dtype: float64
Sorting puts Kochi's long blue bar at the top, and bar_label() writes the exact totals, so readers do not need to read the axis at all.
Quick Quiz
1. What makes a good explanatory chart title?
An explanatory title tells the reader what to notice, such as "Revenue doubled in October".
2. Why use grey for most bars and one strong colour for one bar?
Grey turns the other bars into context, so the highlighted one stands out.
3. A bar chart's y-axis starts at 9.5 instead of 0. What is the problem?
Bar length encodes value, so a cut axis exaggerates differences.
4. Which colour scheme suits values from low to high, such as rainfall amounts?
Sequential single-hue scales show order clearly and work for colour-blind readers.
Learn Data Science with a mentor
Free lessons get you started. Our Data Science & Machine Learning training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
