Data visualisation and storytelling · Beginner

Bar chart best practices: sorting, baselines, labels

12 min read

Why it matters

The bar chart is the chart you will draw most often as an analyst, and the bar chart best practices in this lesson are what a reviewer checks first in a portfolio. Visualisation appears in 94% of the Data Analyst job descriptions we read, and almost every "revenue by region" or "tickets by team" request ends in bars. Getting the small decisions right (order, baseline, labels, stacking) is the difference between a chart people read in two seconds and one they squint at.

The concept

The baseline is zero, always

A bar encodes its value as length from a baseline. If the axis starts at 80, a bar for 90 is twice as long as a bar for 85, though the values differ by 6%. So the value axis of a bar chart starts at zero, with no exceptions.

When zero is not meaningful (temperatures in Celsius, an index that hovers around 100, exam scores between 60 and 75), do not draw bars. Use a dot plot or a line, which encode position rather than length, so a zoomed axis stays honest.

Horizontal bars for long names

Category names like "computers accessories" or "Andaman and Nicobar Islands" do not fit under vertical bars without rotating the text 45 degrees, which nobody reads comfortably. Horizontal bars put the label on the left, in reading direction, at full size. Default to horizontal when labels are longer than about 8 characters or when there are more than 7 or 8 categories.

Sort by value, unless the order means something

An alphabetical order is the order of your groupby, not an order your reader needs. Sort bars by value, biggest at the top, so the ranking is free. The exceptions are categories with a natural order: months, age bands, ratings 1 to 5, salary slabs, funnel stages. Never sort those by value.

Stacked, grouped and 100% stacked

charteasy to comparehard to compareuse when
stackedtotals, and the bottom segmentevery segment above the bottomthe total is the main message
groupedeach sub-category across groupstotalstwo or three sub-categories, totals do not matter
100% stackedthe share in the bottom (and top) segmentabsolute size, middle segmentsshares matter and group sizes differ a lot

The rule underneath: only segments that share a baseline are read accurately. Put the segment you care about on the baseline.

Direct labels beat legends and gridlines

A legend makes the reader look away, match a colour, and look back. A value printed at the end of each bar removes the need for the value axis and its gridlines entirely. For two or three series, write the series name next to the first bar or in the title ("late vs on time") instead of a legend box.

Top N plus Other for long tails

With 70 categories, the bottom 50 bars are unreadable slivers. Show the top 10 or 15 and one "Other N categories" bar. Keep Other at the bottom regardless of its size and in a muted colour, because it is not a category anyone can act on. Always say how many categories Other holds.

Bar width and gaps

Bars should be wider than the gaps between them; in matplotlib the default width=0.8 leaves a gap of a quarter of the bar width. Very thin bars with wide gaps look like a barcode, and bars with no gaps look like a histogram (which signals a continuous variable, not categories). Inside a group in a grouped bar, use no gap between bars and a clear gap between groups.

Diverging bars for positive and negative values

When values can be above or below a reference (profit and loss, change vs last year, difference from a national average), draw bars out from a zero line in both directions, sorted, with one colour for each side. The reference must be stated on the axis.

Worked example: revenue by category, top 15 plus Other

The data is the Olist Brazilian e-commerce public dataset (Kaggle, CC BY-NC-SA 4.0): about 100,000 orders from a Brazilian marketplace, 2016 to 2018, split across linked CSV files. Download and unzip it; you need the orders, order items, products, customers and category translation files. Prices are in Brazilian reais (R$).

The question: which product categories earn the most?

category_revenue.py
import matplotlib.pyplot as plt
import pandas as pd

orders = pd.read_csv("olist_orders_dataset.csv")
items = pd.read_csv("olist_order_items_dataset.csv")
products = pd.read_csv("olist_products_dataset.csv")
names = pd.read_csv("product_category_name_translation.csv")

delivered = orders.loc[orders["order_status"] == "delivered", ["order_id"]]
lines = (items.merge(delivered, on="order_id")
              .merge(products[["product_id", "product_category_name"]], on="product_id", how="left")
              .merge(names, on="product_category_name", how="left"))
lines["category"] = (lines["product_category_name_english"]
                     .fillna(lines["product_category_name"])      # 2 categories have no translation
                     .fillna("(no category)")
                     .str.replace("_", " "))

revenue = lines.groupby("category")["price"].sum().sort_values(ascending=False)
print(len(revenue), "categories, total R$", f"{revenue.sum():,.0f}")

top = revenue.head(15)
other = pd.Series({f"Other {len(revenue) - 15} categories": revenue.iloc[15:].sum()})
plot = pd.concat([top, other]) / 1e6                       # R$ million
print((plot.round(2)).to_string())

fig, ax = plt.subplots(figsize=(7, 6))
y = range(len(plot))[::-1]                                  # biggest at the top
colors = ["#bbb" if name.startswith("Other") else "#3b6ea8" for name in plot.index]
ax.barh(y, plot.values, height=0.7, color=colors)
ax.set_yticks(y, plot.index)
for yi, v in zip(y, plot.values):
    ax.text(v + 0.02, yi, f"{v:.2f}", va="center", fontsize=8)
ax.set_xlim(0, plot.max() * 1.12)
ax.xaxis.set_visible(False)
ax.spines[["top", "right", "bottom"]].set_visible(False)
ax.set_title("Health & beauty and watches lead Olist revenue\nItem revenue, R$ million, delivered orders 2016 to 2018",
             loc="left", fontsize=10)
fig.tight_layout()
fig.savefig("category_revenue.png", dpi=150)
text
74 categories, total R$ 13,221,498
health beauty            1.23
watches gifts            1.17
bed bath table           1.02
sports leisure           0.95
computers accessories    0.89
furniture decor          0.71
housewares               0.62
cool stuff               0.61
auto                     0.58
toys                     0.47
garden tools             0.47
baby                     0.40
perfumery                0.39
telephony                0.31
office furniture         0.27
Other 59 categories      3.13

What the output and chart tell you:

  • 74 categories exist, including two the translation file does not cover (pc_gamer and a kitchen appliances category) and products with no category. The fillna chain keeps them visible instead of silently dropping their revenue in a join. Check the total after every join: R$ 13,221,498 here.

  • The top 15 are close together: health and beauty (1.23) and watches and gifts (1.17) differ by 5%. Without sorting and direct labels, nobody would see that order.

  • The Other bar is the biggest bar on the chart: 59 categories worth R$ 3.13 million, 23.7% of revenue. That is a finding in itself: this marketplace has a long tail, and a "top 15 explains everything" story would be wrong. It sits at the bottom in grey so it does not read as a category to act on, but its size stays honest.

  • ax.xaxis.set_visible(False) removes the value axis because every bar has its value printed. The bars start at zero because barh draws from zero; never pass a left= offset to fake a zoom.

Worked example: late deliveries by state, stacked vs grouped

Second question: which states get late deliveries? An order is late when it arrives after the estimated delivery date the customer was shown.

late_by_state.py
import matplotlib.pyplot as plt
import pandas as pd

orders = pd.read_csv("olist_orders_dataset.csv",
                     parse_dates=["order_delivered_customer_date", "order_estimated_delivery_date"])
customers = pd.read_csv("olist_customers_dataset.csv")
d = (orders[orders["order_status"] == "delivered"]
     .dropna(subset=["order_delivered_customer_date"])
     .merge(customers[["customer_id", "customer_state"]], on="customer_id"))
# The estimate is a date (midnight), so compare calendar dates, not timestamps
d["late"] = d["order_delivered_customer_date"].dt.normalize() > d["order_estimated_delivery_date"]

by_state = d.groupby("customer_state")["late"].agg(orders="size", late="sum")
by_state["on_time"] = by_state["orders"] - by_state["late"]
by_state["late_pct"] = (by_state["late"] / by_state["orders"] * 100).round(1)
national = d["late"].mean() * 100
print(f"{len(d):,} delivered orders, {national:.1f}% late")

top8 = by_state.sort_values("orders", ascending=False).head(8)
print(top8.to_string())

fig, axes = plt.subplots(1, 3, figsize=(14, 4))
x = range(len(top8))
on, late = top8["on_time"] / 1e3, top8["late"] / 1e3

ax = axes[0]                                              # stacked
ax.bar(x, on, color="#9db4cf", label="on time")
ax.bar(x, late, bottom=on, color="#c0504d", label="late")
ax.set_title("Stacked: totals easy, late hard to compare", loc="left", fontsize=10)

ax = axes[1]                                              # grouped
ax.bar([i - 0.2 for i in x], on, width=0.4, color="#9db4cf", label="on time")
ax.bar([i + 0.2 for i in x], late, width=0.4, color="#c0504d", label="late")
ax.set_title("Grouped: late counts comparable, totals gone", loc="left", fontsize=10)

ax = axes[2]                                              # 100% stacked, late at the bottom
share_late = top8["late"] / top8["orders"] * 100
ax.bar(x, share_late, color="#c0504d", label="late")
ax.bar(x, 100 - share_late, bottom=share_late, color="#9db4cf", label="on time")
ax.set_title("100% stacked, late on the baseline", loc="left", fontsize=10)
for i, v in zip(x, share_late):
    ax.text(i, v + 1, f"{v:.0f}%", ha="center", fontsize=8)

for ax in axes:
    ax.set_xticks(list(x), top8.index)
    ax.spines[["top", "right"]].set_visible(False)
axes[0].set_ylabel("orders (thousand)")
axes[2].set_ylabel("% of delivered orders")
axes[0].legend(frameon=False)
fig.tight_layout()
fig.savefig("late_by_state.png", dpi=150)

# Diverging bars: each state's late rate minus the national rate
diff = (by_state["late_pct"] - national).sort_values()
print(diff.round(1).to_string())
fig, ax = plt.subplots(figsize=(6, 7))
labels = [f"{s} ({by_state.loc[s, 'orders']:,})" for s in diff.index]   # n next to each bar
ax.barh(labels, diff.values, color=["#c0504d" if v > 0 else "#3b6ea8" for v in diff.values])
ax.axvline(0, color="#333", lw=0.8)
ax.set_xlabel(f"percentage points vs national late rate ({national:.1f}%)")
ax.set_title("Alagoas and Maranhao run far above the national late rate\nState (delivered orders)", loc="left", fontsize=10)
ax.spines[["top", "right", "left"]].set_visible(False)
fig.tight_layout()
fig.savefig("late_diverging.png", dpi=150)
text
96,470 delivered orders, 6.8% late
                orders  late  on_time  late_pct
customer_state                                 
SP               40494  1820    38674       4.5
RJ               12350  1495    10855      12.1
MG               11354   519    10835       4.6
RS                5344   325     5019       6.1
PR                4923   199     4724       4.0
SC                3546   291     3255       8.2
BA                3256   396     2860      12.2
DF                2080   118     1962       5.7
customer_state
AM    -4.0
RO    -3.9
AP    -3.8
AC    -3.0
PR    -2.8
SP    -2.3
MG    -2.2
DF    -1.1
MT    -0.8
RS    -0.7
GO    -0.3
SC     1.4
RN     2.5
PE     2.8
MS     2.9
TO     3.1
PB     3.6
ES     3.9
PA     4.4
RJ     5.3
RR     5.4
BA     5.4
CE     7.0
PI     7.1
SE     8.4
MA    10.6
AL    14.6

Open late_by_state.png and look at the three panels side by side.

  • Stacked shows that SP (São Paulo) has 40,494 orders, more than three times RJ. But try to compare the red late segments of RJ (1,495) and BA (396): they float at different heights, so you are comparing lengths without a shared baseline.

  • Grouped puts every late bar on the baseline, so late counts compare well. But SP's 1,820 late orders now look like the problem, only because SP is big. Totals are gone.

  • 100% stacked with late on the baseline answers the real question: RJ (12.1%) and BA (12.2%) are late almost three times as often as SP (4.5%). The late segment is on the axis, so it reads accurately. The on-time segment on top reads too, because it shares the 100% line.

That is the general lesson: the same data gives three different answers depending on which segment sits on the baseline. Choose the layout from the question.

The diverging chart (late_diverging.png) shows all 27 states against the national 6.8%. Alagoas (AL) is 14.6 points above it. The order counts in brackets do real work here: Roraima (RR) is 5.4 points above average on just 41 orders (5 late), so its bar is closer to noise than a pattern. Sorting puts the extremes at both ends, and the colour switch at zero shows the reference without a legend.

Common mistakes

  • Starting a bar axis above zero. Small differences look huge. If you need the zoom, switch to dots.

  • Alphabetical or random order. Readers have to hunt for the ranking. Sort by value, except for ordered categories like months or age bands.

  • Rotated labels under vertical bars. Flip to horizontal bars.

  • Putting the important segment in the middle of a stack. Its length floats. Move it to the baseline or use grouped bars.

  • Showing 70 bars. Use top N plus Other, and state N.

  • Sorting Other into the ranking. It is a bucket, not a category. Fix it at the bottom in grey.

  • Legends for a single series. Delete them. For two or three series, label directly.

  • Normalising to 100% without showing the base. Tiny groups look as reliable as big ones.

  • Two colours for positive and negative with no zero line. Draw the reference line and say what it is.

Interview angle

When would you use a stacked bar chart, and what are its weaknesses?

When the total is the main message and the breakdown is secondary, for example total orders per state with the on-time and late split. Its weakness is that only the bottom segment shares a baseline, so every segment above it is hard to compare across bars. If the comparison is between segments, I use grouped bars; if it is between shares, a 100% stacked bar with the segment of interest on the baseline. I also keep stacks to three or four segments.

Why must a bar chart start at zero when a line chart need not?

Bars encode value as length from a baseline, so the ratio of lengths has to equal the ratio of values. Lines and dots encode position, so the reader compares positions against the axis, and zooming does not break that. If zero is not meaningful for the measure, I use dots instead of bars.

You have revenue for 70 categories. How do you chart it?

A sorted horizontal bar of the top 10 or 15 with direct labels, plus one grey "Other N categories" bar at the bottom. I check how big Other is: if it is a quarter of revenue, the long tail is part of the story and I say so. I keep the full table available for anyone who needs a specific category.

Practice task

  1. Run both scripts on the Olist data and confirm your outputs match.

  2. Redraw the category chart as a dot plot with an x-axis that starts at 0.2. Explain in one sentence why this zoom is acceptable for dots and not for bars.

  3. Add a second series to the category chart: revenue in 2017 and in January to August 2018. Decide between grouped bars and a dumbbell (two dots joined by a line) and justify it.

  4. Build a diverging bar of each category's change in revenue, January to August 2018 vs January to August 2017, top 15 categories by 2018 revenue, sorted.

  5. Done when: each chart has a title that states a finding, a unit, direct labels, and a source line.

Hints for step 4

Add order_purchase_timestamp from the orders table to the lines, then filter months 1 to 8 for each year with dt.year and dt.month. Compute (rev_2018 - rev_2017) / rev_2017 * 100. Categories that did not exist in 2017 give infinity: drop them and say so in a note, rather than showing a bar that runs off the chart.

Further reading