Use DataFrame.plot.bar() to compare numeric values across categories. Start with student marks, then build sales reports with grouped, stacked and percentage bars. Learn how to choose colours, label values and save a chart you can use in a report.
Watch the demonstration, then run the examples in the notebook. The student and sales datasets are synthetic and included in the code.
Show Table of ContentsA bar chart compares values across categories. Here each student name is a category and MARK is the numeric value. Select x="NAME" and y="MARK" explicitly so an unrelated numeric ID column would not become another plotted series. Pandas returns a Matplotlib Axes that you can use to adjust labels or save the figure.
import pandas as pd
import matplotlib.pyplot as plt
def student_data():
return pd.DataFrame({
"NAME": ["Ravi", "Raju", "Alex", "Ron", "Geek", "Kim"],
"MARK": [20, 30, 40, 30, 40, 50],
"ENGLISH": [30, 20, 30, 40, 20, 40]
})
df = student_data()
ax = df.plot.bar(x="NAME", y="MARK", title="Student marks",
figsize=(6, 3), rot=0, legend=False)
ax.set_ylabel("Marks")
plt.tight_layout()
plt.show()What to check: Six bars: Ravi 20, Raju 30, Alex 40, Ron 30, Geek 40 and Kim 50.

Use title for a meaningful heading and figsize=(width, height) for dimensions in inches. x supplies category labels; y selects numeric columns. Make the figure wider when labels would otherwise overlap. The DataFrame tutorial explains the table structure; Python tuples supply the size pair.
df = student_data()
ax = df.plot.bar(x="NAME", y="MARK", title="Student marks by name",
figsize=(6, 3), rot=0, legend=False)
ax.set_xlabel("Student")
ax.set_ylabel("Marks")
plt.tight_layout()
plt.show()Without x, the index supplies category labels. Set the names as the index to compare use_index=True and use_index=False. The second chart labels positions 0 through 5. If you want a particular column as the labels, use x explicitly.
df = student_data().set_index("NAME")
fig, axes = plt.subplots(1, 2, figsize=(10, 3))
df.plot.bar(y="MARK", use_index=True, ax=axes[0],
title="Index labels", rot=0, legend=False)
df.plot.bar(y="MARK", use_index=False, ax=axes[1],
title="Row positions", rot=0, legend=False)
plt.tight_layout()
plt.show()

A light horizontal grid helps readers estimate values. Keep the bars in front of the grid. grid=True enables a grid through the plotting options; the Axes methods give more control over direction and appearance.
df = student_data()
ax = df.plot.bar(x="NAME", y="MARK", grid=False,
legend=False, rot=0, figsize=(6, 3))
ax.yaxis.grid(True, alpha=0.25)
ax.set_axisbelow(True)
ax.set_ylabel("Marks")
plt.tight_layout()
plt.show()

One series already named on the axis may not need a legend. Multiple series need clear labels so readers can identify them. legend=False hides the legend; legend=True shows it.
df = student_data()
ax = df.plot.bar(x="NAME", y=["MARK", "ENGLISH"], legend=True,
rot=0, figsize=(7, 3), title="Two subject scores")
ax.set_ylabel("Marks")
plt.tight_layout()
plt.show()

An RGB tuple contains red, green and blue components between 0 and 1. A named colour or hexadecimal string also works. This one-item list assigns the same colour to the MARK series. Use colours consistently across related charts.
df = student_data()
my_colors = [(0.9, 0.4, 0.6)]
ax = df.plot.bar(x="NAME", y="MARK", color=my_colors,
figsize=(5, 3), legend=False, rot=0,
title="Student marks")
plt.tight_layout()
plt.show()
For DataFrame.plot.bar(), a colour list maps to the plotted columns. With only one numeric series, only the first list colour is used. Plot two subjects to use both RGB colours. A dictionary keyed by column name can make that mapping explicit.
df = student_data()
my_colors = [(0.9, 0.4, 0.6), (0.1, 0.3, 0.8)]
ax = df.plot.bar(x="NAME", y=["MARK", "ENGLISH"], color=my_colors,
rot=0, figsize=(7, 3), title="Colours by subject")
plt.tight_layout()
plt.show()
Use a Series bar plot when you want a colour for each category. The colour list below has one RGB tuple per student. Different colours can highlight categories, but a single colour is often clearer when colour does not encode additional information.
df = student_data()
my_colors = [(position / 10.0, position / 20.0, 0.9)
for position in range(len(df))]
ax = df.set_index("NAME")["MARK"].plot.bar(
color=my_colors, rot=0, figsize=(6, 3), title="Colours by student")
ax.set_ylabel("Marks")
plt.tight_layout()
plt.show()
rot controls tick-label rotation. Try 45 degrees for longer names; 180 degrees makes labels upside down and is rarely readable. Keep 0 degrees for short labels. If names are long, horizontal bars often work better.
df = student_data()
ax = df.plot.bar(x="NAME", y="MARK", rot=45,
figsize=(6, 3), legend=False, title="Rotated labels")
plt.tight_layout()
plt.show()

Grouped bars place series side by side within each category. This makes MARK and ENGLISH easier to compare for each student. Both measurements use the same units, so one y-axis works. Choose the numeric columns explicitly.
df = student_data()
ax = df.plot.bar(x="NAME", y=["MARK", "ENGLISH"], stacked=False,
color={"MARK": "#2563a6", "ENGLISH": "#df8b22"},
rot=0, figsize=(7, 3), title="Grouped subject scores")
ax.set_ylabel("Marks")
plt.tight_layout()
plt.show()
stacked=True places series on top of each other. It emphasises totals and composition, while making individual segments harder to compare. Stack values only when adding them answers a meaningful question. Here the total is the combined score across two subjects.
df = student_data()
ax = df.plot.bar(x="NAME", y=["MARK", "ENGLISH"], stacked=True,
figsize=(7, 3), rot=0, title="Combined subject scores")
ax.set_ylabel("Combined marks")
plt.tight_layout()
plt.show()What to check: Kim has a combined score of 90; Geek has 60.

subplots=True creates a separate chart for each numeric series. A list of titles supplies one title per subplot. This is useful when a shared chart would be crowded. Shared y-axis limits help readers compare subjects with the same units.
df = student_data()
axes = df.plot.bar(x="NAME", y=["MARK", "ENGLISH"], subplots=True,
title=["MARK scores", "ENGLISH scores"],
layout=(1, 2), sharey=True, rot=0, legend=False,
figsize=(10, 3))
plt.tight_layout()
plt.show()logy=True makes the numeric axis logarithmic. This can show multiplicative differences when positive values span orders of magnitude. Zero and negative values do not work on a standard log scale. Because bar lengths normally imply a zero baseline, a log-scale line or point chart is often easier to interpret. logx and loglog are plotting options, but they are generally unsuitable for a categorical bar axis.
df = student_data()
ax = df.plot.bar(x="NAME", y="MARK", logy=True,
rot=0, legend=False, title="Marks on a log scale")
ax.set_ylabel("Marks (log scale)")
plt.tight_layout()
plt.show()
# A line plot is another way to demonstrate the numeric log scale.
ax = df.plot.line(x="NAME", y="MARK", logy=True,
legend=False, title="Log-scale comparison")
plt.tight_layout()
plt.show()
A secondary axis uses a different scale. Moving all series to it with secondary_y=True is possible, but placing one identified series on the right is usually more useful. Dual axes can make unrelated changes appear comparable; separate charts are often clearer. Always label both units.
df = pd.DataFrame({"category": ["Stationery", "Office", "Books"],
"revenue": [450, 900, 300], "orders": [9, 6, 12]})
ax = df.plot.bar(x="category", y=["revenue", "orders"],
secondary_y="orders", rot=0, figsize=(7, 3),
title="Revenue and recorded orders")
ax.set_ylabel("Revenue (sample currency units)")
ax.right_ax.set_ylabel("Recorded orders")
plt.tight_layout()
plt.show()

By default the legend adds (right) to a secondary-axis series. mark_right=False removes that suffix, so make the axis association clear another way. The first example below moves MARK to the right axis; the second hides the suffix and names the axis explicitly.
df = student_data()
ax = df.plot.bar(x="NAME", y="MARK", secondary_y=True,
mark_right=True, rot=0, figsize=(6, 3),
title="MARK on the right axis")
plt.tight_layout()
plt.show()
ax = df.plot.bar(x="NAME", y="MARK", secondary_y=True,
mark_right=False, rot=0, figsize=(6, 3),
title="MARK: right-axis scale")
ax.set_ylabel("MARK (right axis)")
plt.tight_layout()
plt.show()

Repeated categories need a summary before plotting: Pandas draws bars for rows, not automatically for unique categories. This synthetic dataset contains recorded revenue by product category and channel. All combinations are observed; the revenue values are non-negative and have the same units.
def sales_data():
return pd.DataFrame({
"category": ["Stationery", "Office", "Books", "Stationery", "Office", "Books"],
"channel": ["Online", "Online", "Online", "Store", "Store", "Store"],
"revenue": [120, 300, 180, 80, 250, 220]
})
sales = sales_data()
totals = sales.groupby("category")["revenue"].sum().sort_values(ascending=False)
print(totals)
assert totals.sum() == sales["revenue"].sum()
assert totals.to_dict() == {"Office": 550, "Books": 400, "Stationery": 200}Expected output
category
Office 550
Books 400
Stationery 200
Name: revenue, dtype: int64Use groupby() to calculate category totals before drawing the chart. Sorting makes the ranking easier to read. Value labels show the exact totals; keep a zero baseline for this non-negative bar chart so bar lengths remain comparable.
sales = sales_data()
totals = sales.groupby("category")["revenue"].sum().sort_values(ascending=False)
ax = totals.plot.bar(color="#2563a6", rot=0, figsize=(7, 4),
title="Recorded revenue by category")
ax.set_xlabel("Product category")
ax.set_ylabel("Revenue (sample currency units)")
ax.set_ylim(0, totals.max() * 1.18)
ax.bar_label(ax.containers[0], fmt="%.0f", padding=3)
ax.yaxis.grid(True, alpha=0.2)
ax.set_axisbelow(True)
plt.tight_layout()
plt.show()
What to check: Office 550, Books 400 and Stationery 200; total recorded revenue is 1,150.
Build a category-by-channel table with pivot_table(). Each channel becomes a numeric series. Use a stable colour for each channel. If a real dataset has absent combinations, decide whether they mean zero sales or missing records before filling them.
sales = sales_data()
report = sales.pivot_table(index="category", columns="channel",
values="revenue", aggfunc="sum")
print(report)
ax = report.plot.bar(stacked=False, rot=0, figsize=(7, 4),
color={"Online": "#2563a6", "Store": "#df8b22"},
title="Recorded revenue by channel")
ax.set_ylabel("Revenue (sample currency units)")
ax.legend(title="Sales channel")
plt.tight_layout()
plt.show()Expected output
channel Online Store
category
Books 180 220
Office 300 250
Stationery 120 80
What to check: For Books, Store revenue is 220 and Online revenue is 180.
Stacking makes category totals visible while retaining channel contributions. These series represent parts of the same recorded revenue total, so adding them is meaningful. Use grouped bars instead when comparing Online values with Store values is the main task.
sales = sales_data()
report = sales.pivot_table(index="category", columns="channel",
values="revenue", aggfunc="sum")
ax = report.plot.bar(stacked=True, rot=0, figsize=(7, 4),
color={"Online": "#2563a6", "Store": "#df8b22"},
title="Channels contributing to category revenue")
ax.set_ylabel("Revenue (sample currency units)")
ax.legend(title="Sales channel")
plt.tight_layout()
plt.show()
assert report.sum().sum() == sales["revenue"].sum()
What to check: Stack heights are Books 400, Office 550 and Stationery 200.
Divide each category row by its total and multiply by 100. This compares channel shares rather than revenue amounts. A category with no positive total needs separate handling. A 100% chart hides total size, so present the category-total chart alongside it when amounts matter.
sales = sales_data()
report = sales.pivot_table(index="category", columns="channel",
values="revenue", aggfunc="sum")
totals = report.sum(axis=1)
assert totals.gt(0).all()
percentages = report.div(totals, axis=0).mul(100)
print(percentages.round(1))
ax = percentages.plot.bar(stacked=True, rot=0, figsize=(7, 4),
color={"Online": "#2563a6", "Store": "#df8b22"},
title="Share of category revenue by channel")
ax.set_ylim(0, 100)
ax.set_ylabel("Share of recorded revenue (%)")
plt.tight_layout()
plt.show()
assert percentages.sum(axis=1).round(6).eq(100).all()Expected output
channel Online Store
category
Books 45.0 55.0
Office 54.5 45.5
Stationery 60.0 40.0
What to check: Stationery is 60% Online and 40% Store; every bar totals 100%.
Save through the returned Axes figure, before closing it. dpi controls raster resolution and bbox_inches="tight" helps include labels. In Colab the file is created in the runtime, not automatically in your Drive: download it from the Files sidebar. See the Colab guide for working with notebook files.
sales = sales_data()
totals = sales.groupby("category")["revenue"].sum().sort_values(ascending=False)
ax = totals.plot.bar(rot=0, color="#2563a6", figsize=(7, 4),
title="Recorded revenue by category")
ax.set_ylabel("Revenue (sample currency units)")
plt.tight_layout()
ax.figure.savefig("pandas_category_sales.png", dpi=150, bbox_inches="tight")
plt.show()
print("Saved pandas_category_sales.png")Expected output
Saved pandas_category_sales.pngUse vertical bars for a small number of categories and horizontal bars for long names. Use line plots for an ordered trend, histograms for a numeric distribution and scatter plots for relationships between two numeric variables. Grouped bars compare series; stacked bars explain a meaningful total; 100% stacked bars compare proportions. Do not mix different units in one stack.
No numeric data to plot: check the selected column dtype and validate text-to-number conversion before plotting. Repeated category labels: aggregate first; use value_counts() when you need counts. Only one list colour appears: DataFrame colour lists map to series; use the Series example for individual bars. Labels overlap: increase figure width, rotate labels or choose horizontal bars. Blank saved image: save the Axes figure before closing it. Missing observations: investigate them before applying fillna(); unknown values are not automatically zero. Misleading comparisons: use clear units, a suitable baseline and consistent category order.
Calculate total recorded revenue for Online and Store, draw a bar chart and label both values. Predict the totals before running the solution. Verify that the two bars add up to the original revenue sum. Expected totals: Online 600 and Store 550.
Group by channel, sum the recorded revenue and display the exact values above the bars. The assertion verifies that the aggregation did not lose any of the six source records.
sales = sales_data()
totals = sales.groupby("channel")["revenue"].sum()
print(totals)
assert totals.to_dict() == {"Online": 600, "Store": 550}
assert totals.sum() == sales["revenue"].sum()
ax = totals.plot.bar(rot=0, color="#2563a6", figsize=(6, 4),
title="Recorded revenue by channel")
ax.set_ylabel("Revenue (sample currency units)")
ax.set_ylim(0, totals.max() * 1.18)
ax.bar_label(ax.containers[0], fmt="%.0f", padding=3)
plt.tight_layout()
plt.show()Expected output
channel
Online 600
Store 550
Name: revenue, dtype: int64Open in Google Colab View on GitHub
Run the examples, change category values and practise choosing grouped or stacked bars. Both sample datasets are included in the notebook. Save a copy in Drive to keep your changes.
Continue with the Pandas Plot guide and plotting exercises. Explore pie, box, density, area and hexbin plots for other questions.
References: Pandas bar-plot documentation and Matplotlib bar labels.
Author & Instructor at plus2net
I write and maintain practical tutorials on Python, PHP, SQL, JavaScript, HTML, jQuery, and web development at plus2net. The tutorials focus on clear explanations, working examples, and code that readers can test and adapt while learning.