PYTHON / MATPLOTLIB
Bar charts and categorical data
Draw, order, group, stack, and label bar charts of categorical data in Matplotlib, and reason about the numeric slots behind category names.
What you will learn
- Call ax.bar/ax.barh; string labels map to positions 0,1,2 in order of first appearance
- Group series with x = np.arange(n) and ±width/2 offsets, then set ticks back to labels
- Stack series by passing a running bottom= array instead of shifting positions
- Choose category order deliberately and keep the value axis anchored at zero
Understanding Bar charts and categorical data
ax.bar(x, height) is a numeric function: it draws a rectangle of a given width centred on each x. When you pass strings, Matplotlib's category unit converter silently maps them to 0, 1, 2, ... in the order it first sees them, and the tick formatter prints the original text back. The default width is 0.8 data units, so each category owns a one-unit slot with a 0.2 gap around it — that gap is what signals to the reader that the axis is discrete rather than continuous. Because length is the encoding, the baseline has to be zero: a bar that starts at 50 no longer has a length proportional to its value.
Once you understand that categories are just integers, multiple series stop being mysterious. Two ax.bar calls with the same string labels land on exactly the same slots at the same width, so the second series hides the first; to group them you must do the arithmetic yourself with x = np.arange(len(labels)), plot at x - width/2 and x + width/2, and keep the total group width below 1.0 so neighbouring groups stay visually separate. Stacking is the other option: leave positions alone and pass bottom=, accumulating each layer's heights so the next layer starts where the previous one ended. Stacks are honest about totals but make every layer above the first hard to compare, since those layers do not share a baseline.
Ordering is a decision, not a default. Nominal categories (libraries, browsers, countries) have no inherent order, so sorting by value turns the chart into a readable ranking; ordinal categories (Mon–Fri, small/medium/large) must keep their own order even if it looks jagged. When labels are long, switch to ax.barh, which draws the first element at y=0 — the bottom — so sort ascending if you want the largest bar on top. Finally, ax.bar_label writes the exact number on each bar, which removes the need for the reader to trace gridlines back to the axis.
import matplotlib
matplotlib.use("Agg") # render without a display
import matplotlib.pyplot as plt
downloads = {"pandas": 41, "numpy": 68, "requests": 55, "flask": 23}
# nominal categories have no natural order, so impose one
labels = sorted(downloads, key=downloads.get, reverse=True)
values = [downloads[name] for name in labels]
fig, ax = plt.subplots(figsize=(5, 3))
bars = ax.bar(labels, values, color="steelblue") # width defaults to 0.8
ax.set_ylabel("downloads / million")
ax.set_ylim(0, 80) # baseline must stay at 0
ax.bar_label(bars, padding=2)
fig.savefig("downloads.png", dpi=100)
print("category order:", labels)
print("slot centres:", [round(float(b.get_x() + b.get_width() / 2), 2) for b in bars])
print("bar width:", float(bars[0].get_width()))
print("heights:", [float(b.get_height()) for b in bars])A bar chart is a numeric plot in disguise: each category becomes an integer slot, so grouping and stacking are just arithmetic on positions and baselines.
Worked examples
Grouped bars from explicit positions
Two series side by side, built by offsetting numeric slots instead of passing strings twice.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
teams = ["backend", "frontend", "data"]
q1 = [12, 9, 4]
q2 = [15, 7, 9]
x = np.arange(len(teams)) # one slot per category
width = 0.38 # two bars must fit inside one slot
fig, ax = plt.subplots()
ax.bar(x - width / 2, q1, width, label="Q1")
ax.bar(x + width / 2, q2, width, label="Q2")
ax.set_xticks(x, teams) # numeric ticks, categorical labels
ax.set_ylabel("tickets closed")
ax.legend()
fig.savefig("grouped.png")
print("slot centres:", x)
print("Q1 positions:", x - width / 2)
print("Q2 positions:", x + width / 2)
print("total group width:", 2 * width)Example explained
Line 1np.arange(len(teams)) reproduces exactly the positions Matplotlib would have generated from the strings.
Line 2Subtracting and adding width/2 places the two bars symmetrically around each slot centre.
Line 32 * width = 0.76 stays under 1.0, so there is still a visible gap between adjacent groups.
Line 4set_xticks(x, teams) puts the category names back on ticks that are now numeric, not categorical.
Stacked bars with a running bottom
Part-to-whole bars where each layer starts at the cumulative height of the layers below it.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
months = ["Jan", "Feb", "Mar"]
parts = {"passed": [40, 45, 52], "failed": [8, 5, 3], "skipped": [2, 4, 1]}
fig, ax = plt.subplots()
bottom = np.zeros(len(months))
for name, values in parts.items():
print(name, "sits on", bottom.copy())
ax.bar(months, values, bottom=bottom, label=name)
bottom += values # next layer starts where this one ended
ax.set_ylabel("test cases")
ax.legend()
fig.savefig("stacked.png")
print("top of stack:", bottom)Example explained
Line 1All three calls reuse the same string categories, so every layer is drawn on the same three slots.
Line 2bottom=bottom shifts the rectangle's baseline up; only the first layer starts at zero.
Line 3bottom += values accumulates in place, which is why the printed bottoms grow between iterations.
Line 4The final bottom array equals the column totals, so it is also the height of each complete bar.
Horizontal bars for long labels
barh with ascending sort so the largest category appears at the top, plus formatted value labels.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
share = {"Chrome": 63.4, "Safari": 20.1, "Edge": 5.2, "Firefox": 2.8}
labels = sorted(share, key=share.get) # ascending: barh fills bottom-up
values = [share[name] for name in labels]
fig, ax = plt.subplots(figsize=(5, 3))
bars = ax.barh(labels, values, color="#4c72b0")
texts = ax.bar_label(bars, fmt="%.1f%%", padding=3)
ax.set_xlim(0, 75)
ax.set_xlabel("share of sessions (%)")
fig.savefig("share.png")
print("draw order (bottom to top):", labels)
print("y positions:", [round(float(b.get_y() + b.get_height() / 2), 1) for b in bars])
print("annotations:", [t.get_text() for t in texts])Example explained
Line 1barh assigns the same integer slots, but on the y axis, and y=0 is at the bottom of the plot.
Line 2Sorting ascending therefore puts Chrome last, i.e. highest on the chart.
Line 3get_height() on a horizontal bar is its thickness (0.8), not its value; the value is get_width().
Line 4bar_label returns the Text objects it created, so you can inspect or restyle them afterwards.
Overlapping series: what goes wrong
Shows that two bar calls with identical string categories draw on top of each other.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
a = ax.bar(["x", "y", "z"], [3, 5, 2], label="first")
b = ax.bar(["x", "y", "z"], [4, 1, 6], label="second")
fig.savefig("overlap.png")
print("first left edges:", [round(float(r.get_x()), 2) for r in a])
print("second left edges:", [round(float(r.get_x()), 2) for r in b])
print("same geometry:", [round(float(r.get_x()), 2) for r in a] == [round(float(r.get_x()), 2) for r in b])Example explained
Line 1"x" is converted to 0 in both calls because the category mapping is stored on the axis and reused.
Line 2Left edge is centre - width/2, so -0.4 for the first slot with the default width of 0.8.
Line 3Identical positions and widths mean the second series paints over the first; nothing is deleted, just hidden.
Line 4The fix is numeric offsets (grouping) or bottom= (stacking), never two plain calls.
Important notes
Category positions are assigned in order of first appearance, not alphabetically, and they are remembered per axis — plotting a second series whose labels arrive in a different order will append new slots instead of reusing the old ones.
width is measured in data units, so on a category axis width=1.0 makes bars touch and anything above 1.0 makes them overlap; ax.bar_label needs Matplotlib 3.4 or newer.
Common mistakes
Calling ax.bar twice with the same string labels and expecting a grouped chart: both series occupy the identical 0.8-wide rectangles, so the first series is completely hidden behind the second.
Trying to offset string categories, e.g. ax.bar(labels - 0.2, ...), which raises a TypeError because you cannot subtract from a list of strings; you need np.arange positions first.
Cropping the value axis with something like ax.set_ylim(50, 70) to 'zoom in': bar length is no longer proportional to the value, so a 5% difference can look like a 5x difference.
Try it yourself
Change, predict, then run
Given commits_2023 = {"Mon": 14, "Tue": 9, "Wed": 22, "Thu": 17, "Fri": 6} and commits_2024 = {"Mon": 11, "Tue": 15, "Wed": 19, "Thu": 21, "Fri": 12}, draw a grouped bar chart that keeps weekday order (do not sort by value) using np.arange positions and width=0.4, then print the two offset arrays you passed to ax.bar.
Open the Python workspaceCheck your understanding
You run ax.bar(['a','b','c'], s1) and then ax.bar(['a','b','c'], s2) on the same axes and see only one set of bars. What actually happened?
- Both series were drawn at positions 0, 1, 2 with the same default width, so the second set covers the first
- The second call cleared the axes and deleted the first series
- Matplotlib only groups series when you pass a group= argument to ax.bar
- String categories are re-sorted alphabetically on each call, so the second series landed off-screen
Show answer
The axis stores one mapping from category name to integer, so 'a' is 0 in both calls, and both use the default width of 0.8 — the rectangles coincide exactly and the later ones are painted on top. The first series was not deleted (you can still inspect its patches), and ax.bar has no group= parameter: grouping is something you produce yourself by offsetting numeric positions.