A plot is a question, not a decoration. This lesson covers the four charts that answer most questions about a new dataset, enough Matplotlib to adjust them, and the one rule about axes that separates an honest chart from a misleading one.
The four that answer most questions
import matplotlib
matplotlib.use('Agg') # no window; write to a file
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
rng = np.random.default_rng(7)
df = pd.DataFrame({
'revenue': rng.gamma(4, 120, 500).round(2),
'units': rng.integers(1, 80, 500),
'region': rng.choice(['North', 'South', 'East'], 500),
'month': rng.choice(['Jan', 'Feb', 'Mar'], 500),
})
fig, axes = plt.subplots(2, 2, figsize=(11, 8))
# 1. Histogram: what does ONE variable look like?
axes[0, 0].hist(df['revenue'], bins=40, color='#0f766e')
axes[0, 0].set_title('Distribution of revenue')
axes[0, 0].set_xlabel('revenue')
# 2. Scatter: how do TWO variables relate?
axes[0, 1].scatter(df['units'], df['revenue'], s=8, alpha=0.4, color='#0f766e')
axes[0, 1].set_title('Revenue against units')
axes[0, 1].set_xlabel('units')
axes[0, 1].set_ylabel('revenue')
# 3. Bar: how does a number compare ACROSS CATEGORIES?
totals = df.groupby('region')['revenue'].sum().sort_values()
axes[1, 0].barh(totals.index, totals.to_numpy(), color='#0f766e')
axes[1, 0].set_title('Revenue by region')
# 4. Line: how does a number change OVER AN ORDERED AXIS?
by_month = df.groupby('month')['revenue'].sum().reindex(['Jan', 'Feb', 'Mar'])
axes[1, 1].plot(by_month.index, by_month.to_numpy(), marker='o', color='#0f766e')
axes[1, 1].set_title('Revenue by month')
axes[1, 1].set_ylim(0)
fig.tight_layout()
fig.savefig('overview.png', dpi=100)
plt.close(fig)
print('wrote overview.png')
Open this example in the editor and change it. Nothing you type is saved.
Choosing between them is a question about the data, not about taste:
- One numeric variable - histogram. How is it spread? Is it skewed? Are there two humps?
- Two numeric variables - scatter. Is there a relationship, and is it straight?
- A number across categories - bar. Which is biggest? Use
barhwhen the labels are long. - A number along an ordered axis, usually time - line. Is it going up?
A pie chart answers "what fraction of the whole", badly, because humans compare angles poorly. A stacked bar does the same job better.
What a histogram tells you that describe() does not
import numpy as np
import pandas as pd
rng = np.random.default_rng(11)
# Two completely different distributions with the same summary.
one = rng.normal(100, 15, 2000)
two = np.concatenate([rng.normal(70, 5, 1000), rng.normal(130, 5, 1000)])
print(pd.DataFrame({'one': one, 'two': two}).describe().round(1))
Open this example in the editor and change it. Nothing you type is saved.
Both have a mean near 100 and a similar standard deviation. One is a single hump; the other is two separate populations with nothing in the middle. describe() cannot tell you that and a histogram tells you instantly.
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(11)
one = rng.normal(100, 15, 2000)
two = np.concatenate([rng.normal(70, 5, 1000), rng.normal(130, 5, 1000)])
fig, axes = plt.subplots(1, 2, figsize=(10, 3.5), sharex=True, sharey=True)
for ax, values, title in zip(axes, [one, two], ['one population', 'two populations']):
ax.hist(values, bins=50, color='#0f766e')
ax.axvline(values.mean(), color='#b91c1c', linestyle='--', label='mean')
ax.set_title(f'{title} (mean {values.mean():.0f})')
ax.legend()
fig.tight_layout()
fig.savefig('two-distributions.png', dpi=100)
plt.close(fig)
print('wrote two-distributions.png')
Open this example in the editor and change it. Nothing you type is saved.
The bin count matters. Too few hides structure; too many turns the chart into noise. Try 30, then 100, and look at both before deciding.
Enough Matplotlib to be dangerous
Matplotlib has two interfaces and the confusion between them is most of its reputation.
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 100)
# The pyplot interface: implicit "current figure". Fine for one chart
# in a notebook, awkward for anything else.
plt.figure(figsize=(6, 3))
plt.plot(x, np.sin(x))
plt.title('implicit')
plt.savefig('implicit.png', dpi=100)
plt.close()
# The object interface: explicit figure and axes. Use this.
fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(x, np.sin(x), label='sin')
ax.plot(x, np.cos(x), label='cos', linestyle='--')
ax.set_title('explicit')
ax.set_xlabel('x')
ax.set_ylabel('value')
ax.legend()
ax.grid(alpha=0.3)
fig.savefig('explicit.png', dpi=100)
plt.close(fig)
print('wrote both')
Open this example in the editor and change it. Nothing you type is saved.
Use fig, ax = plt.subplots() and call methods on ax. It works identically in a notebook, a script and a web request, and it is the only form that works when there is more than one chart.
The methods you will use constantly:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(figsize=(7, 3.5))
x = np.arange(12)
ax.bar(x, np.random.default_rng(3).integers(10, 100, 12), color='#0f766e')
ax.set_title('Monthly total')
ax.set_xlabel('month')
ax.set_ylabel('units')
ax.set_xticks(x)
ax.set_xticklabels(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], rotation=45)
ax.set_ylim(0)
ax.spines[['top', 'right']].set_visible(False)
ax.axhline(50, color='#b91c1c', linestyle='--', linewidth=1, label='target')
ax.legend()
fig.tight_layout()
fig.savefig('monthly.png', dpi=100)
plt.close(fig)
print('wrote monthly.png')
Open this example in the editor and change it. Nothing you type is saved.
pandas plots directly onto an axes, which is the fastest route from a DataFrame to a chart:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
rng = np.random.default_rng(5)
df = pd.DataFrame(
{'North': rng.integers(100, 400, 12), 'South': rng.integers(80, 300, 12)},
index=pd.date_range('2026-01-01', periods=12, freq='MS'),
)
fig, ax = plt.subplots(figsize=(7, 3.5))
df.plot(ax=ax, marker='o') # pass the axes in
ax.set_ylim(0)
ax.set_ylabel('revenue')
ax.set_title('Revenue by region')
fig.tight_layout()
fig.savefig('pandas-plot.png', dpi=100)
plt.close(fig)
print('wrote pandas-plot.png')
Open this example in the editor and change it. Nothing you type is saved.
Always pass ax=. Without it, pandas creates its own figure and you lose control of size, titles and layout.
One rule about axes
A bar chart's value axis starts at zero. Always. The bar's length is the quantity, and a truncated axis makes a 3% difference look like a 300% difference.
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
labels = ['A', 'B', 'C']
values = [98, 100, 102]
fig, axes = plt.subplots(1, 2, figsize=(9, 3.5))
axes[0].bar(labels, values, color='#b91c1c')
axes[0].set_ylim(96, 104)
axes[0].set_title('truncated: looks like a huge difference')
axes[1].bar(labels, values, color='#0f766e')
axes[1].set_ylim(0)
axes[1].set_title('honest: a 4% range')
fig.tight_layout()
fig.savefig('axes.png', dpi=100)
plt.close(fig)
print('the same three numbers, two impressions')
Open this example in the editor and change it. Nothing you type is saved.
A line chart may start elsewhere, because the message is the shape of the change rather than the magnitude of each point - but say so on the axis, and do not do it to make a flat trend look dramatic.
Three more rules worth the same status:
- Label the axes and name the units. "Revenue" is not enough; "Revenue (GBP, thousands)" is.
- Do not use colour as the only signal. About one man in twelve cannot distinguish red from green; add a marker, a line style or a label.
- Sort categorical bars by value, not alphabetically, unless the order means something.
A worked example
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
rng = np.random.default_rng(seed=55)
n = 1500
orders = pd.DataFrame({
'date': pd.Timestamp('2026-01-01') + pd.to_timedelta(rng.integers(0, 180, n), 'D'),
'region': rng.choice(['North', 'South', 'East', 'West'], n, p=[.4, .3, .2, .1]),
'channel': rng.choice(['web', 'phone', 'store'], n, p=[.6, .25, .15]),
'units': rng.integers(1, 60, n),
})
# Revenue depends on units, plus noise - so the scatter has something
# to show, and a small group of very large orders in the tail.
orders['revenue'] = (orders['units'] * rng.normal(12, 2, n) + rng.normal(0, 20, n)).round(2)
orders.loc[rng.choice(n, 25, replace=False), 'revenue'] *= 4
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
# 1. Distribution - and what it reveals that describe() does not.
ax = axes[0, 0]
ax.hist(orders['revenue'], bins=60, color='#0f766e')
ax.axvline(orders['revenue'].mean(), color='#b91c1c', ls='--', label='mean')
ax.axvline(orders['revenue'].median(), color='#1d4ed8', ls='-', label='median')
ax.set_title('Order value is right-skewed')
ax.set_xlabel('revenue (GBP)')
ax.set_ylabel('orders')
ax.legend()
# 2. Relationship, coloured by channel, with a fitted line.
ax = axes[0, 1]
for channel, marker in zip(['web', 'phone', 'store'], ['o', 's', '^']):
subset = orders[orders['channel'] == channel]
ax.scatter(subset['units'], subset['revenue'], s=10, alpha=0.4,
marker=marker, label=channel)
slope, intercept = np.polyfit(orders['units'], orders['revenue'], 1)
grid = np.linspace(orders['units'].min(), orders['units'].max(), 50)
ax.plot(grid, slope * grid + intercept, color='#111827', lw=1.5,
label=f'fit: {slope:.1f} per unit')
ax.set_title('Revenue against units')
ax.set_xlabel('units')
ax.set_ylabel('revenue (GBP)')
ax.legend(fontsize=8)
# 3. Comparison across categories: sorted, horizontal, zero-based.
ax = axes[1, 0]
totals = orders.groupby('region')['revenue'].sum().sort_values()
ax.barh(totals.index, totals.to_numpy() / 1000, color='#0f766e')
for i, value in enumerate(totals.to_numpy() / 1000):
ax.text(value, i, f' {value:,.0f}', va='center', fontsize=9)
ax.set_title('Revenue by region')
ax.set_xlabel('revenue (GBP, thousands)')
ax.set_xlim(0)
ax.spines[['top', 'right']].set_visible(False)
# 4. Change over time: weekly, smoothed, zero-based.
ax = axes[1, 1]
weekly = orders.set_index('date').resample('W')['revenue'].sum() / 1000
ax.plot(weekly.index, weekly.to_numpy(), color='#94a3b8', lw=1, label='weekly')
ax.plot(weekly.index, weekly.rolling(4, min_periods=1).mean().to_numpy(),
color='#0f766e', lw=2, label='4-week mean')
ax.set_title('Revenue over time')
ax.set_ylabel('revenue (GBP, thousands)')
ax.set_ylim(0)
ax.legend()
ax.spines[['top', 'right']].set_visible(False)
fig.suptitle('Orders, first half of 2026', fontsize=13)
fig.tight_layout()
fig.savefig('report.png', dpi=110)
plt.close(fig)
# The numbers the charts are claiming, printed so they can be checked.
print(f'orders {len(orders):,}')
print(f'mean {orders["revenue"].mean():,.2f}')
print(f'median {orders["revenue"].median():,.2f}')
print(f'mean > median {orders["revenue"].mean() > orders["revenue"].median()}')
print(f'revenue per unit, fitted: {slope:.2f}')
print()
print(totals.round(0).to_string())
print()
print('wrote report.png')
Open this example in the editor and change it. Nothing you type is saved.
Every rule in the lesson is applied there. The bar chart starts at zero and is sorted by value. The scatter uses marker shape as well as colour. Both axes are labelled with units. And the numbers the charts claim are printed underneath, so a reader can check the picture against the arithmetic - which is the habit that catches a chart built from the wrong column.
When it goes wrong
| Symptom | Cause |
|---|---|
| The chart is blank | A plt.show() before savefig, or no backend |
| Charts pile up on one figure | Missing plt.close(fig) in a loop |
| A pandas plot ignored your figure | No ax= argument |
| A small difference looks enormous | A bar axis not starting at zero |
| Overlapping points hide the density | Use alpha= and a smaller marker |
| Labels are cut off | Missing fig.tight_layout() |
| A trend is invisible in daily noise | Add a rolling mean |
| A histogram looks like one block | Too few bins, or an outlier stretching the axis |
A check you can run
Take your most recent chart and ask two questions out loud: "what question does this answer?" and "does the axis start at zero?".
If the first has no one-sentence answer, the chart is decoration. If the second is "no" on a bar chart, redraw it - and then look at the two versions side by side. The difference in how big the difference feels is the whole reason the rule exists.