PYTHON / MATPLOTLIB
Histograms and distributions
Draw histograms with ax.hist, choose bin edges deliberately, and switch between counts and density so the bars describe the distribution honestly.
What you will learn
- Call ax.hist(data, bins=n) and read counts and edges from its return value
- Pass an explicit list of bin edges so two datasets share identical bars
- Every bin is half-open except the last, which includes its right edge
- Use density=True so bar areas sum to 1 when widths or sample sizes differ
Understanding Histograms and distributions
A histogram does not draw your data points; it draws a summary that Matplotlib computes from them. ax.hist takes the raw observations, slices the number line into consecutive intervals (bins), counts how many values land in each, and draws one bar per bin. It then returns that summary as a tuple, (counts, edges, patches), so you can print the numbers instead of squinting at the picture to guess them.
Because the bars are counts of intervals, the bin edges decide what shape you see. Passing bins as an integer splits the range from min(data) to max(data) into that many equal-width intervals, so the same values with 5 bins and with 50 bins can look unimodal or spiky. Passing a list of edges instead pins the boundaries yourself, which matters when the boundaries have meaning (decades, price bands) or when two datasets must be counted on the same grid. Values are assigned with half-open intervals, [a, b), with one exception: the final bin also includes its right edge, so the maximum value is never lost.
Heights mean 'count' only while density is False. With density=True each bar height becomes count / (N * bin width), so the total area under the bars is exactly 1 and histograms of a 50-value sample and a 5000-value sample become directly comparable. This also fixes the trap of unequal bin widths: the eye reads area, so a wide bin with a large raw count can look dominant while holding fewer values per unit of x than a narrow neighbour.
import matplotlib
matplotlib.use("Agg") # headless; use plt.show() locally instead
import matplotlib.pyplot as plt
scores = [2, 3, 3, 4, 5, 5, 5, 6, 7, 8, 9, 12]
fig, ax = plt.subplots()
counts, edges, patches = ax.hist(scores, bins=5, edgecolor="white")
ax.set_xlabel("score")
ax.set_ylabel("number of students")
print("edges:", edges.tolist())
print("counts:", counts.astype(int).tolist())
print("bars:", len(patches))
print("nothing dropped:", int(counts.sum()) == len(scores))A histogram plots counts of binned intervals, so the bin edges — not the data alone — decide the shape you see.
Worked examples
Explicit edges and the closed last bin
Shows how values are assigned to half-open intervals and why the maximum value still gets counted.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
data = [2, 3, 3, 4, 5, 5, 5, 6, 7, 8, 9, 12]
fig, ax = plt.subplots()
counts, edges, _ = ax.hist(data, bins=[0, 3, 6, 9, 12])
for i, c in enumerate(counts):
close = "]" if i == len(counts) - 1 else ")"
print(f"[{edges[i]:.0f}, {edges[i+1]:.0f}{close} -> {int(c)}")
print("sum:", int(counts.sum()))Example explained
Line 1bins=[0, 3, 6, 9, 12] gives four intervals directly, so the edges no longer depend on min(data) and max(data).
Line 2[0, 3) excludes 3, which is why both 3s are counted in the second bin, not the first.
Line 3The final interval is closed on the right, so 12 is counted instead of falling off the end.
Line 4counts.sum() equals 12, confirming every value in data landed inside the supplied edges.
density=True with unequal bin widths
Demonstrates that with density on, bar heights are per-unit values and the bar areas sum to 1.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
data = [0.5, 1.5, 1.5, 2.5, 3.5, 5.0, 6.0, 7.0]
fig, ax = plt.subplots()
heights, edges, _ = ax.hist(data, bins=[0, 2, 4, 8], density=True)
ax.set_ylabel("density")
widths = [edges[i + 1] - edges[i] for i in range(len(heights))]
print("heights:", heights.tolist())
print("widths:", widths)
print("total area:", sum(h * w for h, w in zip(heights, widths)))Example explained
Line 1Each height is count / (N * width): the first bin holds 3 of 8 values over a width of 2, giving 3 / 16 = 0.1875.
Line 2The last bin also holds 3 values but is 4 wide, so its bar is the shortest even though it ties for the most values.
Line 3Multiplying each height by its own width and summing gives exactly 1.0, which is the invariant density=True guarantees.
Line 4The y label has to change to 'density'; leaving it as 'count' would now be wrong.
Comparing two groups on shared bins
Overlays two distributions using one fixed edge list so the counts can be read against each other.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
a = [1, 2, 2, 3, 3, 3, 4, 4, 5]
b = [3, 4, 4, 5, 5, 5, 6, 6, 7]
edges = [1, 3, 5, 7]
fig, ax = plt.subplots()
count_a, _, _ = ax.hist(a, bins=edges, histtype="step", label="group A")
count_b, _, _ = ax.hist(b, bins=edges, histtype="step", label="group B")
ax.legend()
print("A:", count_a.astype(int).tolist())
print("B:", count_b.astype(int).tolist())
print("same totals:", count_a.sum() == count_b.sum())Example explained
Line 1Both calls pass the same edges list, so the two sets of bars sit on identical boundaries and can be compared bar by bar.
Line 2histtype="step" draws outlines only, so the second histogram does not paint over the first.
Line 3A's counts fall away to the right while B's build up: the same nine observations, shifted toward larger values.
Line 4Each hist call bins independently; without the shared edges list, B would be binned over its own 3-to-7 range and the bars would not line up.
Important notes
counts is returned as a float array even when density is False, so use counts.astype(int) before indexing with it or formatting it with :d.
NaN values break automatic binning — the range cannot be detected and hist raises a ValueError about a non-finite autodetected range; drop them first, since explicit edges would just skip them without telling you.
Common mistakes
Feeding ax.hist data that is already counted, such as [4, 7, 2] category totals: it bins the numbers 4, 7 and 2 themselves and you get three unrelated bars — that job belongs to ax.bar.
Overlaying two ax.hist calls with bins=20 in each: each dataset is binned across its own min-to-max range, so the bar boundaries differ and any visual comparison is meaningless.
Setting range=(0, 100) when some observations are above 100: those values are silently excluded, so counts.sum() quietly stops matching len(data).
Try it yourself
Change, predict, then run
Plot marks = [38, 42, 55, 61, 61, 67, 68, 70, 70, 72, 74, 74, 74, 79, 83, 88, 91, 91, 95, 99] with bins=list(range(30, 101, 10)), then print the counts and assert they sum to 20.
Open the Python workspaceCheck your understanding
A histogram uses bins=[0, 10, 20, 50] with density=False. The [10, 20) bar has height 60 and the [20, 50] bar has height 90. What is a correct reading?
- More values fall in 20-50, but values are packed more densely in 10-20
- Values are packed more densely in 20-50, because that bar is taller
- The heights were already divided by bin width, so 20-50 is the denser region
- Matplotlib rejects unequal bin widths, so this figure cannot be produced
Show answer
With density=False the height is the raw count, so 90 values spread over 30 units is 3 per unit, while 60 values over 10 units is 6 per unit. Option 3 is tempting but wrong: dividing by bin width only happens when you pass density=True, which is exactly why unequal-width histograms of raw counts mislead the eye.