Honest charts
In short: A misleading chart shows a true table with a false impression (a cut axis, two axes, 3D); an honest one starts bars at zero and states the conclusion in the title.
Problem
“September collapsed”
A colleague sends a chart for the meeting with the manager: the revenue of the last four months. The September bar is almost gone. It looks like a disaster.
September really is weaker: €7,128.80 against €8,812.70 in August. Why, we will find out in the project at the end of the course. Here the question is a different one: how much weaker does it look on the chart, and how much weaker is it really?
The idea
Bars start at zero
The eye reads a bar by its length: a bar twice as long means twice as much. So the length must be the number itself. The colleague's chart starts at 7,000 (set_ylim(7000, 10000)), and that is called a truncated axis. Here are the same numbers with an axis from 7,000 and from 0:
The real fall from August to September is 19.1%. On the left the September bar is 92.9% shorter than the August one: the fall looks 4.9 times bigger. The truncated axis removes the bottom €7,000 the two months share and leaves only the difference.
matplotlib starts bars at 0 by itself. A truncated axis comes from set_ylim or from tools that “zoom in” on the axis on their own. If the difference between the bars does not show from 0, it is small. That is an answer too.
More precisely, if you are curious
A line is different: it is read by its slope and by when it goes up and down, not by length. The axis of a line may start somewhere other than 0 if it is labelled. A line that changes by a few percent looks almost flat on an axis from 0, and the change does not show.
Try it
Fix the chart
Someone drew the revenue by month with an axis from 7,000. Fix the chart: 12 bars with the revenue of each month (monthly, without the cancelled orders), and the y axis starting at 0 and reaching above December.
Solution
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])
items = pd.read_csv('year_items.csv')
items['revenue'] = items['quantity'] * items['unit_price']
per_order = items.groupby('order_id', as_index=False)['revenue'].sum()
orders = orders.merge(per_order, left_on='id', right_on='order_id', how='left', validate='one_to_one')
# Revenue leaves out the cancelled orders.
sold = orders[orders['status'] != 'cancelled']
monthly = sold.set_index('ordered_at')['revenue'].resample('MS').sum()
months = monthly.index.strftime('%Y-%m')
fig, ax = plt.subplots(figsize=(9, 4))
ax.bar(months, monthly)
ax.set_ylim(0, 17000)
ax.set_title('Revenue by month, €')
ax.tick_params(axis='x', rotation=90)
print(monthly.min().round(2), monthly.max().round(2))ax.bar(months, monthly) with ax.set_ylim(0, 17000) shows the weakest month (€7,128.80) a little under half of the strongest (€15,915.90), as it is. With an axis from 7,000 the weak months almost vanish. If you draw the revenue from orders, the cancelled orders get into the bars too.
The idea
Two y axes
Revenue is in thousands of euros, and orders are in hundreds. To put them on one chart, people often give the second line its own axis on the right: ax.twinx() makes a new y axis that shares the x axis with the first. But the scale of each axis is chosen separately, and each line stretches to fill its own. So two lines can look merged or apart, as the author wishes.
The honest alternative is an index: divide every month by the first one and multiply by 100. by_month / by_month.iloc[0] * 100 does it for both columns at once (broadcasting over the columns). Then both lines are on one axis and start at 100:
On the left the lines almost coincide, and revenue and orders seem to move together. On the right you see the difference: in December revenue is 171.2, and orders 136.7. Revenue grew much more than orders, because the December orders are also larger. You saw it in the box plots in the previous lesson too.
Another honest way is two charts one under the other, each with its own axis and label: plt.subplots(2, 1, sharex=True).
The idea
3D, pie charts and pictures
Three things that look impressive and mislead:
- 3D bars and 3D pie charts. Perspective makes what is near bigger than what is far. The third dimension carries no data; it only distorts.
- Pie charts. The eye compares angles and areas much worse than lengths. You saw it in the lesson on choosing a chart: sorted bars say the same more clearly.
- A value in the size of a picture. If the picture grows both in height and in width, its area grows with the square: twice as much looks four times as much.
The idea
Colour for the answer
Colour is not decoration: it tells you where to look. One colour for what matters and grey for the rest is the clearest. Here is the revenue by month twice: on the left all bars are the same, on the right December is coloured:
np.where(monthly.index.month == 12, '#E69F00', 'lightgray') gives one colour per month: orange for December, grey for the others. color=colors puts them on the bars.
The title on the right works too: not “Revenue by month”, but the conclusion: “December brings 14% of annual revenue” (14.3% exactly). The reader gets it before looking at the axes.
The orange #E69F00 is from the Okabe-Ito palette: eight colours that people who cannot see some colours can still tell apart. Many people cannot tell red from green. So never separate two things only by red and green. If you need more colours, matplotlib's tab10 palette (the default colours, C0 to C9) is also a good start.
The idea
Labels instead of a legend
Six lines in six colours and a legend in the corner: the eye jumps between the line and the legend to work out which is which. A label right next to the line saves that. Here is the revenue by category, with a legend on the left and with labels on the right:
ax.text(x, y, 'Kitchen') writes text at the point (x, y) of the data. The months are text, and matplotlib puts them at positions 0, 1, 2…, so len(table) - 0.8 is just right of the last month, and 1.3 is next to December. The kitchen label is in the blue of its line, and the kitchen is drawn last and thicker, so it is on top.
In December the kitchen sells €3,240.60, 4.4 times more than its average month in the rest of the year. Mostly pans and knives sell, probably as gifts. On the left chart this is one of six lines, on the right it is the first thing you see. The gift card has no category, so it is not here.
Try it
Colours
Exercise
The chart for the annual report
The manager wants one chart for the annual report: the revenue by month (monthly), December in colour, the other months in grey, the axis from 0, and a title that says how much of the year's revenue December brings. Store the share in share and the colours in colors.
Solution
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
orders = pd.read_csv('year_orders.csv', parse_dates=['ordered_at'])
items = pd.read_csv('year_items.csv')
items['revenue'] = items['quantity'] * items['unit_price']
per_order = items.groupby('order_id', as_index=False)['revenue'].sum()
orders = orders.merge(per_order, left_on='id', right_on='order_id', how='left', validate='one_to_one')
# Revenue leaves out the cancelled orders.
sold = orders[orders['status'] != 'cancelled']
monthly = sold.set_index('ordered_at')['revenue'].resample('MS').sum()
months = monthly.index.strftime('%Y-%m')
share = monthly['2025-12-01'] / monthly.sum() * 100
colors = np.where(monthly.index.month == 12, '#E69F00', 'lightgray')
fig, ax = plt.subplots(figsize=(9, 4))
ax.bar(months, monthly, color=colors)
ax.set_ylim(0, 17000)
ax.set_title(f'December brings {share:.0f}% of annual revenue')
ax.tick_params(axis='x', rotation=90)December brings 14.3% of annual revenue. If every month were the same, it would be 1/12, about 8.3%. np.where(monthly.index.month == 12, "#E69F00", "lightgray") colours only December, and f"December brings {share:.0f}% of annual revenue" puts the answer in the title. A title like “Revenue by month” only says what is on the axes, and bars that all look the same leave the reader to search alone.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Wide and long: pivot_table and melt · Dates and time series · The first chart · Which chart for which question
- Continues in: Why did revenue fall?
- Tasks on this topic: Charts