Data Analysis with Python (pandas, polars, DuckDB) Module 10 – Visualising data with matplotlib
Choosing the right chart for the question
Pick the chart from the question: sorted bars to compare, lines for trends, histograms for spread, scatter plots for relationships, honest axes.
What you will learn
- Match comparison, trend, distribution, relationship and composition questions to chart types
- Explain why bar charts start at zero and when line charts need not
- Avoid many-slice pies, dual axes and 3D effects
- Write a chart title that states the finding
Before you start
What you’ll learn · Before you start
What you will learn
- Match comparison, trend, distribution, relationship and composition questions to chart types
- Explain why bar charts start at zero and when line charts need not
- Avoid many-slice pies, dual axes and 3D effects
- Write a chart title that states the finding
Before you start
On this page
Twelve branches reported 3,166 thousand in revenue. A sorted bar chart puts Pune and Surat first; an alphabetical export makes the reader hunt for them. Choose the chart from the question it must answer. Bars compare categories, lines show change over time, histograms and box plots show distributions, an ECDF gives cumulative shares, scatter plots preserve pairs of measurements, and stacked bars show parts of a whole. A pie can serve a simple whole with two or three parts.
Then make the reading honest: bars start at zero because their lengths encode values, and the title states a chart finding: A statement supported by the plotted data that identifies the comparison or result a reader should notice. supported by the data. This lesson draws the same invented branch table several ways and measures how sorting, scales and labels change what the reader sees.
The question determines which comparison a chart makes
From the question to the chart
Text description of the diagram
Start from what the reader should see, then pick the chart that shows it:
- Which is bigger? Comparing categories calls for a bar chart sorted by value. The eye compares lengths, so the bars start at zero.
- How did it change? A trend over time calls for a line chart. The eye follows position and slope.
- How are the values spread? A distribution calls for a histogram, a box plot or an ECDF.
- Do two numbers move together? A relationship calls for a scatter plot, which places each item by both numbers.
- What share is each part? Composition calls for a stacked bar, and a pie only when there are two or three parts.
Then write the finding as the title, for example "Pune and Surat bring in 31% of revenue".
Each chart works because it hands the reader one easy comparison. The table says which one, and what goes wrong most often:
| The question | The chart | The eye compares | What goes wrong |
|---|---|---|---|
| Which is bigger? | Bar chart, sorted by value | Lengths from a chart baseline: The reference from which a bar length is measured; a common zero baseline makes ordinary value-bar lengths proportional to their values. | An axis that does not start at zero; alphabetical order |
| How did it change? | Line chart | Position and slope | Uneven time steps; too many lines at once |
| How are the values spread? | Histogram, box plot or ECDF | Shape, quartiles, the share below a value | Bins that hide the shape; one average instead of the spread |
| Do two numbers move together? | Scatter plot | Position along both axes | Thousands of points hiding each other; reading cause into it |
| What share is each part? | Stacked bar; a pie for 2 or 3 parts | Lengths; angles | Many slices; parts that do not add up to a whole |
Three charts describe a spread. A histogram groups numeric values into bins. This lesson uses equal-width bins, whose counts show where values bunch up; explicitly supplied bin edges can also have unequal widths. A box plot shrinks a group to a box from its first to its third quartile, a line at the median and whiskers towards the extremes, which is compact enough to compare a dozen groups in one row. An ECDF, the empirical cumulative distribution function, is a rising curve that gives the fraction of the data at or below any value, with no bins to choose. The distributions lesson of this module builds all three.
Step through the branch question before choosing any software option.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- Ask the question. The quarterly revenue table has twelve branches. The question is a ranking of categories.
- Choose the mark. Bars place all twelve values on a common baseline; the reader compares their lengths.
- Keep the meaning. Time lines and paired scatter points would answer different questions. Keep the category and revenue together.
The same table can support another chart when the question changes; the data shape alone does not choose it.
Key idea
Checkpoint
Check: Question and chart
Sorting twelve bars makes the ranking the reading order
A chain of 12 branches exported its quarterly revenue, in thousands, in alphabetical order. The numbers are invented for this lesson and stay fixed:
branch,revenue
Bhopal,198
Coimbatore,173
Guwahati,151
Indore,236
Jaipur,315
Kochi,251
Lucknow,279
Mysuru,162
Nagpur,224
Pune,520
Surat,470
Vadodara,187 Every chart in the lesson uses the same small style file, which matplotlib reads with plt.style.use: dark text,
grey axes, a light grid and one blue for the bars that carry the message.
# house.mplstyle: one look for every chart in this lesson.
# Use it with plt.style.use("house.mplstyle") from the folder that holds this file.
# Size and text
figure.figsize: 5.0, 3.3
figure.dpi: 100
font.size: 13
axes.titlesize: 13.5
axes.titleweight: bold
axes.titlelocation: left
axes.titlepad: 10
# Ink: dark text, grey axes, a light grid that stays in the background
text.color: "#1c2027"
axes.labelcolor: "#1c2027"
axes.edgecolor: "#6b7280"
xtick.color: "#4b5563"
ytick.color: "#4b5563"
grid.color: "#d6dbe1"
axes.spines.top: False
axes.spines.right: False
axes.axisbelow: True
# Colours: one blue for what matters, greys for the rest
axes.prop_cycle: cycler("color", ["1d5fcb", "6b7280", "1c2027", "4b5563"])
patch.facecolor: "#6b7280"
lines.linewidth: 2
legend.frameon: False
# Saved files: the same bytes on every run, text kept as text
savefig.bbox: tight
savefig.pad_inches: 0.15
svg.fonttype: none
svg.hashsalt: house The question is “which branches bring in the most?”, so the chart is a bar chart sorted by value, with the two
leaders in blue. Horizontal bars leave room for the branch names, and ax.bar_label prints each value at the end of
its bar:
import matplotlib.pyplot as plt
import pandas as pd
plt.style.use("house.mplstyle")
# Quarterly revenue of 12 branches, in thousands (invented numbers), in the order the export lists them.
revenue = pd.read_csv("branches.csv", index_col="branch")["revenue"]
ranked = revenue.sort_values() # smallest first: barh draws the first bar at the bottom
top_two = ranked.index[-2:][::-1]
share = ranked.iloc[-2:].sum() / ranked.sum()
fig, ax = plt.subplots(figsize=(5.0, 4.6))
colours = ["#1d5fcb" if branch in top_two else "#6b7280" for branch in ranked.index]
bars = ax.barh(ranked.index, ranked.to_numpy(), color=colours)
ax.bar_label(bars, padding=3)
ax.set_xlim(0, 600)
ax.set_xlabel("Revenue this quarter (thousands)")
ax.set_title(f"{top_two[0]} and {top_two[1]} bring in {share:.0%} of revenue")
fig.savefig("sorted-bars.svg", metadata={"Date": None})
print("bars from the top:", ", ".join(ranked.index[::-1]))
print("value axis starts at:", ax.get_xlim()[0])
print(f"title: {ax.get_title(loc='left')!r} ({ranked.iloc[-2:].sum()} of {ranked.sum():,})") Output
bars from the top: Pune, Surat, Jaipur, Lucknow, Kochi, Indore, Nagpur, Bhopal, Vadodara, Coimbatore, Mysuru, Guwahati value axis starts at: 0.0 title: 'Pune and Surat bring in 31% of revenue' (990 of 3,166)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sorted_bars.py
The chart sorted_bars.py draws
Text description of the diagram
A horizontal bar chart of quarterly revenue for 12 branches, in thousands, sorted from largest at the top to smallest at the bottom, with each value printed at the end of its bar. The value axis runs from 0 to 600.
The two largest branches are drawn in blue and the rest in grey: Pune 520, Surat 470, then Jaipur 315, Lucknow 279, Kochi 251, Indore 236, Nagpur 224, Bhopal 198, Vadodara 187, Coimbatore 173, Mysuru 162 and Guwahati 151.
The title states the finding: Pune and Surat bring in 31 % of revenue (990 of 3,166).
The title is computed from the data, so it stays true when the numbers change next quarter. Now the same 12 numbers drawn the ways that export tools and slide decks often draw them, with what each version actually tells a reader:
import pandas as pd
revenue = pd.read_csv("branches.csv", index_col="branch")["revenue"]
total = revenue.sum()
largest, smallest = revenue.idxmax(), revenue.idxmin()
# 1. Bars sorted by value: the ranking is the reading order.
ranked = revenue.sort_values(ascending=False)
print("sorted bars:", ", ".join(ranked.index[:4]), "... down to", smallest)
# 2. Bars in the export's alphabetical order: where do the two largest sit?
order = list(revenue.index)
places = [order.index(b) + 1 for b in ranked.index[:2]]
print(f"export order: the two largest are bars {places[0]} and {places[1]} of {len(order)}")
# 3. Bars on an axis that starts at 140 instead of 0: compare drawn lengths with the data.
floor = 140
drawn = (revenue[largest] - floor) / (revenue[smallest] - floor)
true = revenue[largest] / revenue[smallest]
print(f"axis from {floor}: {largest}'s bar is {drawn:.1f} times {smallest}'s; the data say {true:.1f} times")
# 4. A 12-slice pie: each slice's angle, and the closest pair of neighbouring slices.
angles = (revenue / total * 360).sort_values(ascending=False)
gaps = angles.diff(-1).dropna()
a, b = gaps.idxmin(), angles.index[angles.index.get_loc(gaps.idxmin()) + 1]
middle = angles.iloc[4:9]
print(f"12-slice pie: slices 5 to 9 span {middle.min():.1f} to {middle.max():.1f} degrees")
print(f"closest neighbours: {a} {angles[a]:.2f} and {b} {angles[b]:.2f} degrees, {gaps.min():.2f} apart") Output
sorted bars: Pune, Surat, Jaipur, Lucknow ... down to Guwahati export order: the two largest are bars 10 and 11 of 12 axis from 140: Pune's bar is 34.5 times Guwahati's; the data say 3.4 times 12-slice pie: slices 5 to 9 span 21.3 to 28.5 degrees closest neighbours: Mysuru 18.42 and Guwahati 17.17 degrees, 1.25 apart
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 five_ways.py
- Sorted bars. The ranking is the reading order: Pune, Surat, Jaipur and so on down to Guwahati.
- Alphabetical bars. The two largest branches are bars 10 and 11 of 12, so the reader has to scan all twelve bars to find the leaders.
- An axis from 140. Pune’s bar is drawn 34.5 times as long as Guwahati’s, while Pune’s revenue is 3.4 times Guwahati’s: the chart exaggerates the gap tenfold.
- A 12-slice pie. Five of the slices lie between 21.3 and 28.5 degrees, and the two closest neighbours differ by 1.25 degrees. Nobody ranks those by eye, and twelve labels crowd round the edge.
- 3D bars. Depth adds no data. Perspective makes the nearer bars look larger, the bars in front hide the ones behind, and the top of a 3D bar cannot be read against the axis. The UK Government Analysis Function’s chart guidance lists 3D shapes among the effects to leave out.
Follow the same twelve values from the export order into the sorted chart.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- Read twelve values. Keep every branch in the comparison, including Guwahati at 151 thousand.
- Sort by revenue. The two largest values become adjacent at the top of the horizontal chart.
- State the share. The title's rounded share is computed from the same table that supplies the bars.
Only the order changes. That is why sorting helps the eye find the leaders without altering the accounting.
Key idea
Checkpoint
Check: Ranking and reading order
A cut bar baseline changes the ratio of drawn lengths
A bar shows its value by its length, so the length has to start at zero. Cut the axis and every bar loses the same amount, which the smallest bars cannot afford:
import matplotlib.pyplot as plt
import pandas as pd
plt.style.use("house.mplstyle")
revenue = pd.read_csv("branches.csv", index_col="branch")["revenue"]
three = revenue[["Pune", "Kochi", "Guwahati"]]
fig, (honest, cut) = plt.subplots(2, 1, figsize=(5.0, 5.8), layout="constrained")
for ax, floor in ((honest, 0), (cut, 140)):
ax.bar(three.index, three.to_numpy(), color="#6b7280")
ax.set_ylim(floor, 560)
drawn = (three["Pune"] - floor) / (three["Guwahati"] - floor)
ax.set_title(f"Axis from {floor}: Pune looks {drawn:.1f}x")
ax.tick_params(axis="x", labelsize=11)
honest.set_ylabel("Revenue (thousands)")
cut.set_ylabel("Revenue (thousands)")
fig.savefig("zero-or-cut.svg", metadata={"Date": None})
for ax in (honest, cut):
print(f"{ax.get_title(loc='left')!r}: value axis {ax.get_ylim()[0]:.0f} to {ax.get_ylim()[1]:.0f}")
print(f"the data: Pune {three['Pune']} / Guwahati {three['Guwahati']} = {three['Pune'] / three['Guwahati']:.1f}x") Output
'Axis from 0: Pune looks 3.4x': value axis 0 to 560 'Axis from 140: Pune looks 34.5x': value axis 140 to 560 the data: Pune 520 / Guwahati 151 = 3.4x
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 baselines.py
The chart baselines.py draws
Text description of the diagram
Two bar charts of the same revenue, in thousands: Pune 520, Kochi 251 and Guwahati 151.
- Top, value axis from 0 to 560: the bars are proportional to the values, and Pune's bar is 3.4 times Guwahati's.
- Bottom, value axis from 140 to 560: every bar loses 140, so Guwahati's bar is only 11 long against Pune's 380, and Pune looks 34.5 times as large. Kochi, which earns two thirds more than Guwahati, looks ten times as large.
If starting at zero leaves the differences too small to see, the differences are probably not the story, or a dot plot, which marks each value with a dot instead of a bar, is the better chart.
A line shows its values by their position and the slope between them, so the reader’s eye follows the shape, not the distance from zero. A zoomed axis is acceptable when the range is where the values live and the chart says so. An on-time delivery rate that stays between 90 % and 100 % is a good example:
import matplotlib.pyplot as plt
plt.style.use("house.mplstyle")
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
on_time = [94.1, 94.6, 93.8, 94.9, 95.3, 92.6, 91.8, 92.4, 94.0, 94.8, 95.2, 95.6] # % of deliveries on time
fig, (full, zoom) = plt.subplots(2, 1, figsize=(5.0, 5.8), sharex=True, layout="constrained")
for ax, (low, high) in ((full, (0, 100)), (zoom, (90, 100))):
ax.plot(months, on_time, color="#1d5fcb", marker="o", markersize=4)
ax.set_ylim(low, high)
ax.set_xticks(months[::3])
ax.grid(axis="y")
full.set_title("Axis from 0 %")
zoom.set_title("Axis from 90 %, labelled")
full.set_ylabel("On time (%)")
zoom.set_ylabel("On time (%)")
zoom.annotate("Axis starts at 90 %", xy=(0.02, 0.04), xycoords="axes fraction", fontsize=10, color="#4b5563")
fig.savefig("trend-baselines.svg", metadata={"Date": None})
drop = max(on_time[:5]) - min(on_time[5:8])
for ax in (full, zoom):
low, high = ax.get_ylim()
print(f"{ax.get_title(loc='left')}: the dip of {drop:.1f} points fills {drop / (high - low):.0%} of the height") Output
Axis from 0 %: the dip of 3.5 points fills 4% of the height Axis from 90 %, labelled: the dip of 3.5 points fills 35% of the height
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trend_baselines.py
The chart trend_baselines.py draws
Text description of the diagram
Two line charts of the share of deliveries on time in each month, from January to December: 94.1, 94.6, 93.8, 94.9, 95.3, 92.6, 91.8, 92.4, 94.0, 94.8, 95.2 and 95.6 %.
- Top, y-axis from 0 % to 100 %: the dip from 95.3 % in May to 91.8 % in July, 3.5 points, fills 4 % of the plot's height and the line looks flat.
- Bottom, y-axis from 90 % to 100 %, with the note "Axis starts at 90 %": the same dip fills 35 % of the height, so the mid-year trouble and the recovery to 95.6 % in December are easy to see.
On the full axis the mid-year dip of 3.5 points fills 4 % of the plot’s height, and the line looks flat. On the zoomed axis the same dip fills 35 %, which shows the trouble clearly, but also makes it look larger. So a zoomed axis needs three things: a round starting value, a label that says where the axis starts, and a sentence in the text that says it too, because a screen reader user meets the chart through that text.
Step through the subtraction that makes the cut chart misleading.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- Keep the data. The two revenues are unchanged whichever axis range the author chooses.
- Start at zero. Lengths of 520 and 151 have the same ratio as the revenues.
- Cut at 140. Subtracting the same baseline from both gives a drawn ratio of about 34.5 instead.
The distortion is caused by the baseline, not a change in the branch data.
Key idea
Checkpoint
Check: Baselines and magnification
A crossing on two y-axes is controlled by their separate scales
Pies. A pie asks the reader to compare angles, and slices that differ by a degree or two look the same, as the 1.25-degree gap above shows. Keep pies for two or three parts that add up to a meaningful whole, such as cash and digital payments. The UK guidance is a little looser: five categories or fewer, a meaningful whole and one category that clearly dominates. For anything else, sorted bars answer the same question better.
Dual axes. A chart with two y-axes draws each line against its own scale, and someone chose both scales. Change one range and the lines move against each other, so whether and where they cross is a decision of the person who drew the chart, not a fact about the data:
# Two series on two y-axes: where the lines cross depends only on the axis ranges someone picked.
orders = [40, 42, 45, 47, 50, 52, 55, 57, 60, 62, 64, 66] # thousands, left axis 30 to 70
basket = [310, 305, 312, 318, 322, 330, 335, 338, 344, 350, 352, 358] # average order value, right axis
def height(values, low, high):
"""Where each value is drawn, as a share of the plot's height (0 = bottom, 1 = top)."""
return [(v - low) / (high - low) for v in values]
left = height(orders, 30, 70)
for low, high in [(300, 360), (250, 400), (280, 340)]:
right = height(basket, low, high)
above = [r > l for l, r in zip(left, right)]
switches = [m + 1 for m in range(1, 12) if above[m] != above[m - 1]]
story = f"the lines cross in month {switches[0]}" if switches else "the lines never cross"
leader = "basket line on top at the end" if above[-1] else "orders line on top at the end"
print(f"right axis {low} to {high}: {story}; {leader}") Output
right axis 300 to 360: the lines cross in month 10; basket line on top at the end right axis 250 to 400: the lines cross in month 5; orders line on top at the end right axis 280 to 340: the lines never cross; basket line on top at the end
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dual_axes.py
The same two series cross in month 10, in month 5 or never, depending only on the right axis. Draw two charts stacked above each other with a shared time axis instead, or divide each series by its first value and multiply by 100, so that both start at 100 and share one axis.
3D effects. A 3D chart of two-dimensional data adds a dimension that carries nothing and makes every bar harder to read. Leave it out, and leave out shadows and textures for the same reason.
Follow what changes in dual_axes.py while its input lists remain fixed.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- Hold both series. The twelve order counts and basket values stay fixed.
- Change a range. Different right-axis limits move the apparent crossing.
- Share meaning. Use indexed change on one scale, or separately labelled panels sharing time.
A crossing between unlike scales cannot establish that one quantity overtook the other.
Key idea
Checkpoint
Check: Wholes and separate scales
A finding title tells the reader which comparison matters
A title that names the topic, such as “Revenue by branch”, makes every reader find the message alone. A title that states the finding, such as “Pune and Surat bring in 31% of revenue”, tells them what to look for and what to remember. The UK guidance pairs such a headline title with a subtitle that says what the data is, which place it covers and which period it shows: “Revenue of 12 branches, in thousands, all regions, first quarter”.
Keep the title as strong as the evidence and no stronger. “Orders rose after the new menu” is a finding; “The new
menu raised orders” is a claim about cause that one line chart cannot support. And compute numbers in titles from
the data, as sorted_bars.py does, so a chart redrawn next quarter cannot keep last quarter’s message.
Build the headline from the branch table rather than from last quarter’s copied wording.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- Find the leaders. Use the same ranked values that drew the bars.
- Compute their share. Divide their combined revenue by all twelve branches' revenue.
- Name the scope. The subtitle identifies the period, units and population behind the result.
The result and the scope do different jobs: the title gives the finding; the subtitle identifies the data.
Key idea
Checkpoint
Check: Titles from evidence
Words and mark styles carry meaning when colours cannot
Use colour for meaning, not decoration. In the sorted chart, blue marks the two branches the title talks about and grey marks the rest, so the colour repeats the message instead of competing with it.
Colour must never be the only way to tell things apart. WCAG 2.2, the web’s accessibility standard, says so in success criterion 1.4.1, and it matters to the many readers who see some colours poorly. Label bars and lines directly, and vary the line style or the marker as well as the colour when several series share a chart. Criterion 1.4.11 asks for a contrast of at least 3:1 between the parts of a chart that are needed to understand it, such as bars and lines, and their background.
When you do need several colours, start from a palette designed for colour-vision deficiencies. matplotlib ships several as style sheets, among them these two:
import matplotlib.pyplot as plt
# Two colour cycles made for readers with colour-vision deficiencies ship with matplotlib as style sheets.
for name in ["tableau-colorblind10", "petroff10"]:
with plt.style.context(name):
colours = plt.rcParams["axes.prop_cycle"].by_key()["color"]
print(f"{name}: {len(colours)} colours, starting {', '.join(c.lower() for c in colours[:4])}")
# The house style of this lesson: one blue for what matters, greys for the rest.
with plt.style.context("house.mplstyle"):
print("house.mplstyle:", ", ".join(plt.rcParams["axes.prop_cycle"].by_key()["color"])) Output
tableau-colorblind10: 10 colours, starting #006ba4, #ff800e, #ababab, #595959 petroff10: 10 colours, starting #3f90da, #ffa90e, #bd1f01, #94a4a2 house.mplstyle: #1d5fcb, #6b7280, #1c2027, #4b5563
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 palettes.py
plt.style.use("tableau-colorblind10") or plt.style.use("petroff10") switches the default colours of every chart
that follows. The petroff palettes come from a published study that chose colour sequences people like while keeping
the colours apart under simulated colour-vision deficiencies and in greyscale.
An informative chart needs an equivalent text alternative (WCAG 2.2 criterion 1.1.1). For the complex charts here, provide a short identification and finding, plus a longer description with the essential values and trends. The figures in this lesson carry both; open the description under a figure to read its long version.
Bar Graph Maker Draw a quick sorted bar chart from your own numbers and check that its value axis starts at zero. Line Graph Maker Plot a trend from a column of values to see how the axis range changes the story.Trace the branch finding through its colour, labels and text alternative.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- Keep the finding. Pune and Surat remain named in the title and directly labelled beside their values.
- Add another cue. Line patterns and markers can separate series without relying on hue.
- Supply the content. A reader who cannot see the chart receives its essential comparisons and values in text.
The meaning survives when the colour difference does not.
Key idea
Checkpoint
Check: Colour and alternatives
Practice: redraw the branch export
The export tool drew the branches as vertical bars in alphabetical order, on an axis from 140, under the title
“Revenue by branch”. Rewrite draw_revenue so that it answers “which branches bring in the most?” at a glance. The
checks inspect the Axes your function draws on, not a picture: the kind of bars, their order, where the value axis
starts, the axis label, the title and the value labels. The starter code passes none of the 6 checks:
import pandas as pd
def draw_revenue(ax, revenue: pd.Series) -> None:
"""Draw the branches' revenue on `ax` so that "which branches bring in the most?" is answered at a glance.
`revenue` holds one value per branch, in thousands, in the export's alphabetical order.
"""
# Starter code: the chart as the export tool drew it.
ax.bar(revenue.index, revenue.to_numpy())
ax.set_ylim(140, 560)
ax.set_title("Revenue by branch") import matplotlib.pyplot as plt
import pandas as pd
from matplotlib.container import BarContainer
from redraw_task import draw_revenue
revenue = pd.read_csv("branches.csv", index_col="branch")["revenue"]
fig, ax = plt.subplots()
draw_revenue(ax, revenue)
bars = [c for c in ax.containers if isinstance(c, BarContainer)]
container = bars[0] if bars else None
horizontal = container is not None and container.orientation == "horizontal"
if horizontal:
# Bar lengths from the bottom bar to the top bar.
lengths = [p.get_width() for p in sorted(container.patches, key=lambda p: p.get_y())]
value_axis = ax.get_xlim()
unit = ax.get_xlabel()
else:
lengths = []
value_axis = ax.get_ylim()
unit = ax.get_ylabel()
titles = " ".join(ax.get_title(loc=where) for where in ("left", "center", "right"))
labels = [t.get_text() for t in ax.texts]
checks = {
"horizontal bars, one per branch": horizontal and len(container.patches) == len(revenue),
"sorted: the largest bar at the top": bool(lengths) and lengths == sorted(lengths),
"the value axis starts at 0": value_axis[0] == 0,
"the value axis names its unit (thousands)": "thousand" in unit.lower(),
"the title names the largest branch": revenue.idxmax() in titles,
"every bar shows its value": sorted(labels) == sorted(str(v) for v in revenue),
}
for name, ok in checks.items():
print("pass" if ok else "FAIL", name)
print(f"{sum(checks.values())} of {len(checks)} checks pass") Output
FAIL horizontal bars, one per branch FAIL sorted: the largest bar at the top FAIL the value axis starts at 0 FAIL the value axis names its unit (thousands) FAIL the title names the largest branch FAIL every bar shows its value 0 of 6 checks pass
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_redraw.py
Start with revenue.sort_values() and ax.barh, which draws the first bar at the bottom, so the largest value ends
up at the top. Then ax.bar_label, ax.set_xlim(0, …), an x-axis label with the unit, and a title computed from
revenue.idxmax(). Run check_redraw.py again until all 6 checks pass.
Practice
Choose marks for a fresh branch report, then check whether the baseline, scale and headline preserve its meaning.
Practice that adapts to you
Practise this lesson
18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · Why should a bar chart’s value axis start at zero, when a line chart’s axis need not?
A bar shows its value by its length, measured from the baseline. If the axis starts above zero, every bar loses the same amount, so the drawn lengths stop being proportional to the values: on an axis from 90, bars for 100 and 110 are drawn 10 and 20 long, and 110 looks twice 100. A line shows its values by their position and the slopes between them, so readers compare the shape, not the distance from zero, and a zoomed axis can show a real change that would look flat from zero. A zoomed line chart still exaggerates small changes, so it should start at a round value, label where the axis starts and say so in the text. If a bar chart’s differences vanish from zero, use a dot plot or show the differences themselves.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
Leadership wants a pie chart of the company's 14 cost categories for the annual summary. What do you propose instead, and how do you deal with the smallest categories without hiding anything?
-
Mid to senior
A product manager wants one chart of daily active users and daily revenue over six months, on two y-axes, to show that revenue follows usage. What do you build instead, and what do you say about the claim?
Key takeaways
- Start from the question: sorted bars compare, lines show trends, histograms, box plots and ECDFs show spread, scatter plots show relationships, and stacked bars show parts of a whole.
- Bars start at zero because the eye compares their lengths; an axis from 140 drew Pune’s lead 10 times too large.
- A line chart may zoom in when the range is the point, as long as the chart and the text say where the axis starts.
- Use a pie for a simple whole of two or three parts, and avoid misleading dual scales and decorative 3D effects.
- The title states the finding, computed from the data, and the subtitle states the data and the period.
- Never rely on colour alone, and give every published chart a text alternative.
Terms in this lesson
- chart baseline
- The reference from which a bar length is measured; a common zero baseline makes ordinary value-bar lengths proportional to their values.
- chart finding
- A statement supported by the plotted data that identifies the comparison or result a reader should notice.
Corrections
- Added chart-choice scenes, checkpoints and adaptive practice; clarified histogram bins and text alternatives while retaining the branch charts and redraw worksheet.
References
- Data visualisation: charts (Government Analysis Function (UK))
- Web Content Accessibility Guidelines (WCAG) 2.2: 1.4.1 Use of Color (W3C)
- Web Content Accessibility Guidelines (WCAG) 2.2: 1.1.1 Non-text Content (W3C)
- Web Content Accessibility Guidelines (WCAG) 2.2: 1.4.11 Non-text Contrast (W3C)
- Quick start guide (Matplotlib) (Matplotlib development team)
- Style sheets reference (Matplotlib) (Matplotlib development team)
- matplotlib.axes.Axes.bar_label (Matplotlib development team)
- Accessible Color Sequences for Data Visualization (Petroff) (arXiv)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress