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 meaningless
  • width and 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, barh beats 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)
ChartAnswersCommon trap
scatterDo two measures relate?Overplotting hides density; use alpha or hexbin
histHow is one variable distributed?Bin count changes the story; try several
boxplotSpread and outliers per groupHides a bimodal shape as one box
errorbarA value with uncertaintyUnlabelled bars could be sd, se or CI
pieParts of a wholeAngles 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.

Titles, legends, annotations and text Colormaps, colour mapping and accessibility

Last refreshed 2026-09-18.