Charts and Visualisation 📈
A chart is an argument. This lesson covers how to draw one in Python, and how to make sure the argument it makes is true.
The simplest chart
import matplotlib # pip install matplotlib
matplotlib.use("Agg") # render to a file, no window needed
import matplotlib.pyplot as plt
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
sales = [100, 120, 118, 135, 150, 162]
fig, ax = plt.subplots(figsize=(8, 4.5))
ax.plot(months, sales, marker="o")
ax.set_title("Monthly sales, 2026")
ax.set_xlabel("Month")
ax.set_ylabel("Sales (£000s)")
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig("sales.png", dpi=150)
print("written to sales.png")
The pattern is always the same: make a figure and axes, draw on the axes, label
everything, save or show. matplotlib.use("Agg") tells it to render to a file
rather than open a window, which is what you want on a server or in a script.
Choosing the right chart
| Question | Chart | Notes |
|---|---|---|
| How has this changed over time? | Line | Time on the x-axis, always |
| How do these categories compare? | Bar | Horizontal if the labels are long |
| What is the distribution? | Histogram | The one people forget, and often the most informative |
| Are these two things related? | Scatter | Correlation, not causation. Say so |
| What are the parts of a whole? | Stacked bar | Not a pie chart. See below |
| Where are the outliers? | Box plot | Shows median, quartiles and stragglers at once |
Humans compare angles badly and lengths well. A pie chart with more than about four slices is harder to read than the bar chart of the same data, and two pie charts side by side are nearly impossible to compare. Use a bar chart. If someone insists on a pie, sort the slices and never explode them.
Several charts at once
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(seed=42)
data = rng.normal(loc=100, scale=15, size=500)
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
axes[0].hist(data, bins=30, edgecolor="white")
axes[0].set_title("Distribution")
axes[1].boxplot(data, vert=False)
axes[1].set_title("Spread and outliers")
axes[2].scatter(data[:-1], data[1:], alpha=0.4, s=12)
axes[2].set_title("Each value against the next")
for ax in axes:
ax.grid(alpha=0.3)
fig.suptitle("Three views of the same 500 numbers")
fig.tight_layout()
fig.savefig("three-views.png", dpi=150)
print("saved")
Note the seeded random generator: default_rng(seed=42) makes the figure
reproducible, which matters as much for a chart in a report as it does for a test.
The chart nobody labels properly
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
regions = ["East", "North", "South", "West"]
totals = [4200, 2090, 1490, 980]
fig, ax = plt.subplots(figsize=(8, 4.5))
bars = ax.barh(regions, totals, color="#4584b6")
ax.set_title("Total sales by region, Q2 2026")
ax.set_xlabel("Sales (£)")
ax.bar_label(bars, fmt="£{:,.0f}", padding=4)
ax.set_xlim(0, max(totals) * 1.15)
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
fig.savefig("regions.png", dpi=150)
print("saved")
A chart is finished when it has:
- a title that states the finding, not just the subject;
- axis labels with units;
- a source and a date, if anyone else will see it;
- no more ink than the data needs. Remove gridlines, borders and legends that earn nothing.
How charts lie
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
months = ["Jan", "Feb", "Mar", "Apr"]
values = [100, 102, 101, 104]
fig, (left, right) = plt.subplots(1, 2, figsize=(11, 4))
left.bar(months, values, color="#c92a2a")
left.set_ylim(99, 105)
left.set_title("'Explosive growth!'")
right.bar(months, values, color="#4584b6")
right.set_ylim(0, 120)
right.set_title("The same data, honest axis")
fig.tight_layout()
fig.savefig("truncated.png", dpi=150)
print("saved")
The left chart is not false. Every number on it is correct. It is simply drawn so that a four percent change fills the frame and reads as a quadrupling.
Truncating a bar chart's y-axis is the most common deception in business presentations, and it is usually not malice: it is someone letting the plotting library pick the limits. Bar charts must start at zero, because the bar's length is the message. Line charts may not, because the slope is the message.
The other reliable ways to mislead, in case you meet them:
- Cherry-picked ranges. Any trend can be reversed by choosing the start date.
- Two y-axes. Almost always used to imply a relationship between unrelated series, and the scales can be tuned until the lines agree.
- Area for a single number. Doubling a circle's radius quadruples its area, so the reader sees a 4x change in a 2x quantity.
- Correlation presented as cause. Ice cream sales and drownings rise together. Neither causes the other; summer causes both.
Other tools
| Tool | Good for |
|---|---|
| matplotlib | Everything, eventually. Verbose but total control |
| seaborn | Statistical charts in one line, sensible defaults |
| plotly | Interactive charts for web pages |
| pandas .plot() | Quick looks straight from a DataFrame |
| Altair | Declarative: you describe the mapping, it draws it |
import pandas as pd
df = pd.DataFrame({"month": ["Jan", "Feb", "Mar"], "sales": [100, 120, 118]})
# the fastest possible look at some data
ax = df.plot(x="month", y="sales", kind="bar", title="Sales", figsize=(6, 3))
ax.figure.savefig("quick.png")
Draw a distribution
Generate 1,000 dice-roll totals for two dice, plot them as a histogram, and label it properly. Seed the generator so it is reproducible.
Reveal solution
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import random
random.seed(42)
totals = [random.randint(1, 6) + random.randint(1, 6) for _ in range(1000)]
fig, ax = plt.subplots(figsize=(8, 4.5))
ax.hist(totals, bins=range(2, 14), align="left", rwidth=0.85, color="#4584b6")
ax.set_title("Two dice, 1,000 rolls: seven is the most likely total")
ax.set_xlabel("Total of both dice")
ax.set_ylabel("Number of rolls")
ax.set_xticks(range(2, 13))
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
fig.savefig("dice.png", dpi=150)
print("saved dice.png")The title states the finding rather than naming the chart. That single habit improves reports more than any styling.
Fix the misleading chart
A colleague sends a chart showing 'a 300% increase in engagement'. The y-axis runs from 4.0 to 4.3 and the x-axis covers eleven days. What do you say?
Reveal solution
Something like: the axis starts at 4.0, so a 0.3 change fills the whole frame. In absolute terms this is a 7% move, not 300%, and the 300% figure appears to be the change relative to the truncated baseline rather than to zero. Eleven days is also short enough that normal weekly variation could explain it. Could we see it from zero, over a quarter, with the weekly cycle visible?
Note the tone. The chart is nearly always an honest mistake by someone who let the library choose the limits. Asking to see it differently gets a better chart; accusing someone of lying gets a defensive colleague.
Chart selection
Which chart for each?
- Website visitors per day for a year.
- Revenue from five product lines this quarter.
- How long users spend on a page.
- Whether taller people earn more.
- The share of traffic from four sources, compared across three months.
Reveal solution
- Line. Time series. Consider a seven-day rolling average to reveal the trend under the weekly cycle.
- Bar, horizontal if the product names are long, sorted by value.
- Histogram. The mean is nearly useless here: this distribution will be heavily skewed by a few very long sessions.
- Scatter, with a note that any correlation is not causation, and that confounders like age and occupation are doing much of the work.
- Stacked bar, three bars side by side. Three pie charts would make the comparison nearly impossible.