Chart types: bar, scatter, histogram, pie and box
The everyday chart functions, the arguments that matter, and the specific ways each chart can mislead a reader.
Comparing categories
import numpy as np
import matplotlib.pyplot as plt
labels = ["Q1", "Q2", "Q3", "Q4"]
this_year = np.array([12, 18, 15, 24])
last_year = np.array([10, 14, 16, 20])
fig, ax = plt.subplots(figsize=(7, 4))
x = np.arange(len(labels))
ax.bar(x - 0.2, this_year, width=0.4, label="2026")
ax.bar(x + 0.2, last_year, width=0.4, label="2025")
ax.set_xticks(x, labels)
ax.set_ylabel("Revenue (GBP millions)")
ax.legend()
# stacked instead of grouped
ax.bar(labels, this_year, label="new")
ax.bar(labels, last_year, bottom=this_year, label="renewal")
# horizontal bars when labels are long
ax.barh(["Enterprise renewal", "SMB onboarding"], [31, 12])
ax.set_xlim(0, 40) # or the comparison is meaninglesswidthand the offset between groups control readability; bars should not touch.- Always start bar axes at zero, and state the unit in the axis label.
- Grouped bars compare at most three series before they become unreadable; beyond that use small multiples.
- For long category names,
barhbeats rotating the tick labels 90 degrees.
Relationships and distributions
rng = np.random.default_rng(0)
x = rng.normal(size=400)
y = x * 0.6 + rng.normal(size=400) * 0.5
ax.scatter(x, y, s=18, c=np.abs(y), cmap="viridis", alpha=0.6, edgecolor="none")
ax.set_xlabel("Spend (GBP)")
ax.set_ylabel("Retention score")
ax.hist(y, bins=30, density=True, histtype="step", label="all users")
ax.hist(y[y > 0], bins=30, density=True, histtype="step", label="positive")
ax.legend()
ax.errorbar([1, 2, 3], [2.1, 3.4, 2.8], yerr=[0.3, 0.5, 0.2],
fmt="o", capsize=4, label="mean +/- sd")
ax.boxplot([x, y], tick_labels=["spend", "retention"])
ax.violinplot([x, y], showmedians=True)| Chart | Answers | Common trap |
|---|---|---|
scatter | Do two measures relate? | Overplotting hides density; use alpha or hexbin |
hist | How is one variable distributed? | Bin count changes the story; try several |
boxplot | Spread and outliers per group | Hides a bimodal shape as one box |
errorbar | A value with uncertainty | Unlabelled bars could be sd, se or CI |
pie | Parts of a whole | Angles are poorly judged; use a bar chart instead |
When a chart misleads
# a pie with ten slices communicates nothing
ax.pie(np.array([30, 20, 12, 9, 8, 7, 6, 4, 3, 1]),
labels=[f"cat{i}" for i in range(10)], autopct="%1.1f%%")
# three bars and a line on one axis: two different scales, zero information
ax.bar(labels, [3, 4, 5, 6]) # counts in hundreds
ax2 = ax.twinx()
ax2.plot(labels, [0.31, 0.42, 0.55, 0.61]) # a rate between 0 and 1
# the honest version: two panels with separate axes
fig, (top, bottom) = plt.subplots(2, 1, sharex=True, figsize=(7, 5))⚠️
A second y-axis lets you place two series wherever you like, which is why it is so often used to imply a relationship that the data does not support. Split into two panels instead.
FAQ
How many bins should a histogram have?
There is no single answer — plot several bin counts and check that the shape is stable. If the story flips between 20 and 40 bins, the story is the binning, not the data.
Is a pie chart ever acceptable?
Only for two or three slices that visibly sum to a whole. For anything more, a sorted horizontal bar chart is easier to read and to compare.
Related
Titles, legends, annotations and text Colormaps, colour mapping and accessibility
Last refreshed 2026-09-18.