Python Primer·Chapter 19
Visualisation That Isn't Decoration
Charts that answer a question rather than fill a slide. Matplotlib's object model, the six chart types that carry nearly all the load, and the difference between a plot for you and a plot for other people.
A chart is an argument. Before drawing one, be able to finish this sentence: this chart shows that…
If you cannot, you are decorating rather than communicating, and the reader will sense it.
Matplotlib Has Two APIs. Learn One.
Matplotlib gives you two ways to draw the same chart, and they look similar enough to mix by accident.
# The pyplot API — implicit and stateful
plt.plot(x, y)
plt.title("Revenue")
plt.show()
# The object-oriented API — explicit
fig, ax = plt.subplots()
ax.plot(x, y)
ax.set_title("Revenue")
The first style has a hidden “current axes” that commands apply to. It works for one chart and becomes unpredictable the moment you have two, or you are inside a function, or you want to return a figure rather than display it.
Use the second style. It is barely longer and it always does what it says.
The mental model is three objects:
flowchart TB
F["<b>Figure</b><br/>the whole canvas<br/>size, resolution, saving"]
F --> A1["<b>Axes</b><br/>one plot area<br/>titles, labels, limits"]
F --> A2["<b>Axes</b><br/>another plot area"]
A1 --> E1["<b>Artists</b><br/>lines, bars, points, text"]
A2 --> E2["<b>Artists</b>"]
style F fill:#f0efe9,stroke:#5c6670
style A1 fill:#eef1fc,stroke:#2141c8
A Figure is the canvas. An Axes is one plot area on it, and confusingly it is what most people mean by “a chart”. Everything drawn inside is an artist.
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(daily.index, daily.revenue)
ax.set_xlabel("Date")
ax.set_ylabel("Revenue (£)")
ax.set_title("Daily revenue, 2026")
fig.savefig("revenue.png", dpi=150, bbox_inches='tight')
bbox_inches='tight' crops the whitespace and stops long labels being cut off. Use it every time you save.
Several plots at once:
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
axes[0, 0].hist(df.age)
axes[0, 1].scatter(df.age, df.revenue)
fig.tight_layout() # stops labels overlapping
Choosing the Chart
Six types cover nearly everything. Pick by the question, not by appearance.
| Question | Chart |
|---|---|
| How is one variable distributed? | Histogram |
| How do distributions compare across groups? | Box plot, or overlaid histograms |
| Do two numeric variables relate? | Scatter |
| How has something changed over time? | Line |
| How do categories compare in size? | Horizontal bar |
| How do many small groups compare? | Small multiples |
Note what is missing. Pie charts are hard to read because people judge angles poorly, and anything with more than three slices is worse than a bar chart. Dual axes invite the reader to see a relationship that the axis scaling invented. Avoid both.
Distribution
fig, ax = plt.subplots()
ax.hist(df.revenue, bins=50)
ax.set_xlabel("Revenue")
ax.set_ylabel("Customers")
The bin count changes the story, so try several. Too few hides structure; too many turns it into noise. For skewed data, a log scale often reveals a shape that is invisible otherwise:
ax.set_xscale('log')
Relationship
fig, ax = plt.subplots()
ax.scatter(df.age, df.revenue, alpha=0.3, s=10)
alpha is essential once you have more than a few hundred points. Without transparency, a dense region is a solid blob and you cannot see where the mass is. Above about fifty thousand points, switch to hexbin or a 2D histogram.
Change over time
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(daily.index, daily.revenue, alpha=0.4, label='daily')
ax.plot(daily.index, daily.revenue.rolling(7).mean(), lw=2, label='7-day mean')
ax.legend()
Raw daily data plus a rolling average is the standard treatment. The faint raw series shows the noise; the bold line shows the trend.
Comparing categories
counts = df.city.value_counts().head(15)
fig, ax = plt.subplots()
ax.barh(counts.index[::-1], counts.values[::-1])
Horizontal, so the labels are readable. Sorted, so the comparison is easy. Alphabetical ordering is almost never what the reader wants.
Bar charts must start at zero. Truncating the axis exaggerates differences, and doing it deliberately is misleading.
Small multiples
Underused and frequently the right answer. Rather than cramming eight series onto one chart, draw eight small charts sharing axes.
regions = df.region.unique()
fig, axes = plt.subplots(2, 4, figsize=(14, 6), sharey=True, sharex=True)
for ax, region in zip(axes.flat, regions):
sub = df[df.region == region]
ax.plot(sub.date, sub.revenue)
ax.set_title(region, fontsize=10)
fig.tight_layout()
sharey=True is what makes it work. Without a shared scale the panels are not comparable and the layout is actively misleading.
Seaborn
Seaborn sits on matplotlib and handles statistical plots and grouping with far less code.
import seaborn as sns
sns.histplot(df, x='revenue', hue='tier', bins=40)
sns.boxplot(df, x='tier', y='revenue')
sns.scatterplot(df, x='age', y='revenue', hue='tier', alpha=0.5)
sns.heatmap(df.corr(numeric_only=True), annot=True, cmap='coolwarm', center=0)
The hue argument does the work that would otherwise be a loop and a legend.
FacetGrid produces small multiples in one call:
g = sns.FacetGrid(df, col='region', col_wrap=4, height=2.5, sharey=True)
g.map_dataframe(sns.lineplot, x='date', y='revenue')
Every seaborn function returns matplotlib objects, so you can always drop down to the lower level to adjust something:
ax = sns.boxplot(df, x='tier', y='revenue')
ax.set_ylabel("Revenue (£)")
ax.set_title("Revenue by tier")
Use seaborn for statistical charts, matplotlib for control. They are not alternatives.
Making a Chart Legible
The difference between a chart that communicates and one that does not is mostly small things.
Label the axes with units. “Revenue” is worse than “Revenue (£m)”. A reader should not have to ask what the numbers are.
Write the title as the finding, not the subject.
ax.set_title("Revenue") # a label
ax.set_title("Revenue fell 12% after the March change") # an argument
The second title does the work. If you cannot write one, reconsider whether the chart is worth showing.
Annotate the point you are making.
ax.annotate("pricing change",
xy=(change_date, value), xytext=(change_date, value * 1.3),
arrowprops=dict(arrowstyle='->', color='#5c6670'))
One arrow saves a paragraph of explanation.
Remove what is not carrying information.
ax.spines[['top', 'right']].set_visible(False)
ax.grid(axis='y', alpha=0.3)
Gridlines should be faint enough to read past. Borders on two sides are enough.
Format the numbers as a human would write them.
from matplotlib.ticker import FuncFormatter
ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"£{v:,.0f}"))
Choose colour deliberately. About one in twelve men has some colour vision deficiency, and red against green is the common problem. Use a colourblind-safe palette, and never rely on colour alone to distinguish series.
sns.set_palette('colorblind')
Print your chart in greyscale once. If it still works, it will work for everyone.
Two Kinds of Chart
Be clear which you are making, because the standards are different.
Exploratory charts are for you. Make dozens, make them ugly, make them fast. Default styling is fine. Their job is to show you something you did not know, and most will be discarded within a minute.
df.hist(figsize=(12, 8), bins=30) # every numeric column at once
df.plot(subplots=True, figsize=(10, 8))
Explanatory charts are a deliverable. One idea each, titled with the finding, labelled with units, annotated where it matters, and readable at the size it will actually be seen. These take twenty minutes and deserve it.
The mistake is applying explanatory standards during exploration, which is slow, or exploratory standards to a deliverable, which is careless.
What Carries Forward
Use fig, ax = plt.subplots(). The stateful API breaks as soon as there is more than one chart.
Title with the finding, not the subject. If you cannot state a finding, the chart may not be worth making.
Use alpha on scatter plots, sort your bar charts, start bars at zero.
Small multiples with a shared axis beat one crowded chart nearly every time.
Know whether the chart is for you or for someone else, and apply the right standard.
Next: the statistics you need to know whether what you are looking at is real.