Learn to build bar charts, line charts, scatter plots, heatmaps, and multi-panel dashboards with matplotlib and seaborn. Covers the Figure/Axes model, seaborn's statistical charts, professional styling, and annotation — all with realistic sales data you can run immediately.

You've spent hours cleaning messy data, joining tables, and running groupby aggregations. You've got a DataFrame with real answers in it. Now someone asks you to explain what you found — and a table of numbers isn't going to cut it. This is where visualization earns its place in your workflow: not as decoration, but as the final step that makes your analysis actually land.
matplotlib and seaborn are the two workhorses of Python data visualization. matplotlib gives you low-level control over every pixel of a chart. seaborn sits on top of it and gives you statistically-aware, publication-quality charts with far less code. In practice, you'll use both — seaborn for fast, expressive charts and matplotlib to fine-tune the result.
By the end of this lesson, you'll know how to build the charts that actually come up in real analysis: distributions, comparisons, time series, correlations, and multi-panel dashboards. More importantly, you'll understand why you'd choose each chart type and how to make them readable for someone who hasn't seen your data before.
What you'll learn:
You should be comfortable loading and transforming data with pandas — specifically grouping and aggregating with groupby and selecting and filtering data with loc and boolean masks. You should have a working Python environment with Jupyter or VS Code — if you're not set up yet, see Setting Up Python for Data Analysis.
Install the libraries if needed:
pip install matplotlib seaborn pandas
Before writing a single chart, you need to understand how matplotlib thinks. This trips up almost every new user, and if you skip it, your charts will fight you constantly.
matplotlib has two core objects:
A Figure can contain one or many Axes objects. This is what allows you to create multi-panel dashboards.
import matplotlib.pyplot as plt
# The simplest approach: one figure, one axes
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [10, 25, 18])
ax.set_title("My Chart")
plt.show()
The fig, ax = plt.subplots() pattern is the professional way to create charts. You'll see older code that just calls plt.plot() directly — that works for quick exploration, but it uses an implicit "current axes" under the hood that causes confusion once you start building multi-panel layouts.
Key insight
Always use fig, ax = plt.subplots() explicitly. When you have multiple subplots, ax becomes an array: fig, axes = plt.subplots(2, 2) gives you a 2x2 grid where axes[0, 0] is the top-left chart.
For a multi-panel layout:
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
axes[0].plot([1, 2, 3], [10, 20, 15])
axes[0].set_title("Left Chart")
axes[1].bar(["A", "B", "C"], [30, 45, 22])
axes[1].set_title("Right Chart")
plt.tight_layout()
plt.show()
plt.tight_layout() adjusts spacing so titles and labels don't overlap. Get in the habit of calling it before plt.show().
Throughout this lesson, we'll work with a realistic sales dataset — the kind you'd have after joining order data with product and customer tables. Here's how to generate it so you can follow along:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
np.random.seed(42)
n = 500
regions = ["Northeast", "Southeast", "Midwest", "West"]
categories = ["Software", "Hardware", "Services", "Consulting"]
months = pd.date_range("2023-01-01", periods=12, freq="MS")
df = pd.DataFrame({
"order_date": np.random.choice(months, n),
"region": np.random.choice(regions, n),
"category": np.random.choice(categories, n),
"sales_rep": np.random.choice([f"Rep_{i}" for i in range(1, 16)], n),
"revenue": np.random.lognormal(mean=8, sigma=1.2, size=n).round(2),
"units": np.random.randint(1, 50, n),
"customer_satisfaction": np.random.uniform(2.5, 5.0, n).round(1),
})
# Add some realistic correlations
df["revenue"] = df["revenue"] * (df["category"] == "Software").astype(int) * 1.4 + \
df["revenue"] * (df["category"] != "Software").astype(int)
df["order_date"] = pd.to_datetime(df["order_date"])
print(df.head())
print(df.dtypes)
This gives you 500 rows with a date column, categorical dimensions, and numeric measures — exactly the structure you'll encounter in real analysis.
Bar charts are the most misused chart in business. People apply them to everything. But they're genuinely the right tool when you're comparing a single numeric value across discrete categories — "revenue by region," "tickets by department," "headcount by role."
# Aggregate first, then plot
revenue_by_region = (
df.groupby("region")["revenue"]
.sum()
.sort_values(ascending=False)
.reset_index()
)
fig, ax = plt.subplots(figsize=(8, 5))
ax.bar(
revenue_by_region["region"],
revenue_by_region["revenue"],
color="#2563EB",
edgecolor="white",
linewidth=0.5
)
ax.set_title("Total Revenue by Region", fontsize=14, fontweight="bold", pad=15)
ax.set_xlabel("Region", fontsize=11)
ax.set_ylabel("Total Revenue ($)", fontsize=11)
# Format y-axis as dollars
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
# Remove top and right spines for a cleaner look
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()
Sorting the bars (sort_values(ascending=False)) before plotting is almost always the right call. A reader should immediately see the ranking — that's the point of the chart.
Now let's make a grouped bar chart to show revenue by region and category at the same time. This is where seaborn saves you significant work:
revenue_by_region_cat = (
df.groupby(["region", "category"])["revenue"]
.sum()
.reset_index()
)
fig, ax = plt.subplots(figsize=(11, 6))
sns.barplot(
data=revenue_by_region_cat,
x="region",
y="revenue",
hue="category",
palette="Set2",
ax=ax
)
ax.set_title("Revenue by Region and Category", fontsize=14, fontweight="bold", pad=15)
ax.set_xlabel("Region", fontsize=11)
ax.set_ylabel("Total Revenue ($)", fontsize=11)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.legend(title="Category", bbox_to_anchor=(1.01, 1), loc="upper left")
plt.tight_layout()
plt.show()
Tip
Notice we pass ax=ax to seaborn functions. This is critical. Without it, seaborn creates its own figure, which breaks multi-panel layouts and makes it impossible to combine with matplotlib customizations. Always pass the ax parameter.
Line charts are for time series — that's essentially their only job, and they're excellent at it. If your x-axis isn't time (or some other naturally ordered sequence), a bar chart is almost certainly better.
Let's plot monthly revenue over time. This is a natural follow-on from working with dates and time series in pandas:
monthly_revenue = (
df.groupby("order_date")["revenue"]
.sum()
.reset_index()
.sort_values("order_date")
)
fig, ax = plt.subplots(figsize=(11, 5))
ax.plot(
monthly_revenue["order_date"],
monthly_revenue["revenue"],
color="#2563EB",
linewidth=2.5,
marker="o",
markersize=6,
label="Monthly Revenue"
)
# Add a rolling average for trend
monthly_revenue["rolling_avg"] = monthly_revenue["revenue"].rolling(3, center=True).mean()
ax.plot(
monthly_revenue["order_date"],
monthly_revenue["rolling_avg"],
color="#DC2626",
linewidth=2,
linestyle="--",
label="3-Month Rolling Avg"
)
ax.set_title("Monthly Revenue Trend — 2023", fontsize=14, fontweight="bold", pad=15)
ax.set_xlabel("Month", fontsize=11)
ax.set_ylabel("Revenue ($)", fontsize=11)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.legend(fontsize=10)
# Rotate x-axis labels so they don't overlap
fig.autofmt_xdate(rotation=45)
plt.tight_layout()
plt.show()
The rolling average line is doing real analytical work here — it smooths out noise so the viewer can see whether the trend is moving up or down. This is the difference between a chart that decorates your analysis and one that explains it.
For multiple lines (one per category):
monthly_by_cat = (
df.groupby(["order_date", "category"])["revenue"]
.sum()
.reset_index()
.sort_values("order_date")
)
fig, ax = plt.subplots(figsize=(12, 6))
palette = sns.color_palette("Set1", n_colors=4)
for i, cat in enumerate(monthly_by_cat["category"].unique()):
subset = monthly_by_cat[monthly_by_cat["category"] == cat]
ax.plot(
subset["order_date"],
subset["revenue"],
label=cat,
color=palette[i],
linewidth=2,
marker="o",
markersize=4
)
ax.set_title("Monthly Revenue by Category", fontsize=14, fontweight="bold", pad=15)
ax.set_xlabel("Month", fontsize=11)
ax.set_ylabel("Revenue ($)", fontsize=11)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.legend(title="Category", fontsize=10)
fig.autofmt_xdate(rotation=45)
plt.tight_layout()
plt.show()
Warning
Don't put more than 5-6 lines on a single chart. Above that, the chart becomes a spaghetti mess that nobody can read. If you have more categories, consider using facets (small multiples) instead — we'll cover that shortly.
When you need to understand how a variable spreads — not just its average — you need a distribution chart. These are the charts that analysts use heavily internally, even if they show cleaner summaries to executives.
fig, ax = plt.subplots(figsize=(9, 5))
ax.hist(
df["revenue"],
bins=40,
color="#2563EB",
edgecolor="white",
linewidth=0.5
)
ax.set_title("Distribution of Order Revenue", fontsize=14, fontweight="bold", pad=15)
ax.set_xlabel("Revenue ($)", fontsize=11)
ax.set_ylabel("Number of Orders", fontsize=11)
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
# Add a vertical line for the median
median_rev = df["revenue"].median()
ax.axvline(median_rev, color="#DC2626", linestyle="--", linewidth=2, label=f"Median: ${median_rev:,.0f}")
ax.legend(fontsize=10)
plt.tight_layout()
plt.show()
Notice the revenue follows a log-normal distribution (we generated it that way to simulate realistic sales data, where a few big deals skew the mean heavily). The vertical median line makes this immediately visible to any reader.
seaborn's histplot adds even more options including a KDE (kernel density estimate) overlay:
fig, ax = plt.subplots(figsize=(9, 5))
sns.histplot(
data=df,
x="revenue",
hue="category",
bins=30,
kde=True,
palette="Set2",
alpha=0.6,
ax=ax
)
ax.set_title("Revenue Distribution by Category", fontsize=14, fontweight="bold", pad=15)
ax.set_xlabel("Revenue ($)", fontsize=11)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()
Box plots are underutilized outside of data science circles, which is a shame — they convey median, quartiles, and outliers in a single compact view.
fig, ax = plt.subplots(figsize=(9, 5))
sns.boxplot(
data=df,
x="category",
y="revenue",
palette="Set2",
linewidth=1.5,
flierprops={"marker": "o", "markersize": 3, "alpha": 0.5},
ax=ax
)
ax.set_title("Revenue Distribution by Category", fontsize=14, fontweight="bold", pad=15)
ax.set_xlabel("Category", fontsize=11)
ax.set_ylabel("Revenue ($)", fontsize=11)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()
Key insight
The box shows the interquartile range (Q1 to Q3), the line inside is the median, the whiskers extend to 1.5× IQR, and the dots beyond that are outliers. If you're presenting to an audience unfamiliar with box plots, add a small annotation explaining this — or switch to a violin plot (sns.violinplot) which is more intuitive for non-technical audiences.
When you want to understand the relationship between two continuous variables — does higher satisfaction correlate with larger deals? does more units sold mean more revenue? — scatter plots are your tool.
fig, ax = plt.subplots(figsize=(9, 6))
scatter = ax.scatter(
df["units"],
df["revenue"],
c=df["customer_satisfaction"],
cmap="RdYlGn",
alpha=0.6,
s=40,
edgecolors="none"
)
# Add a colorbar to explain the color encoding
cbar = plt.colorbar(scatter, ax=ax)
cbar.set_label("Customer Satisfaction", fontsize=10)
ax.set_title("Revenue vs Units Sold\n(color = customer satisfaction)", fontsize=13, fontweight="bold", pad=15)
ax.set_xlabel("Units Sold", fontsize=11)
ax.set_ylabel("Revenue ($)", fontsize=11)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()
This scatter plot encodes three variables: units (x-axis), revenue (y-axis), and satisfaction (color). That's the maximum you should pack into one chart — four variables becomes unreadable.
seaborn's scatterplot and regplot add regression lines with confidence intervals in one line:
fig, ax = plt.subplots(figsize=(8, 5))
sns.regplot(
data=df,
x="units",
y="revenue",
scatter_kws={"alpha": 0.4, "s": 30},
line_kws={"color": "#DC2626", "linewidth": 2},
ax=ax
)
ax.set_title("Revenue vs Units Sold (with trend line)", fontsize=13, fontweight="bold", pad=15)
ax.set_xlabel("Units Sold", fontsize=11)
ax.set_ylabel("Revenue ($)", fontsize=11)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()
Heatmaps are perfect for two scenarios: correlation matrices and pivot table data where you want to spot patterns across two categorical dimensions.
numeric_cols = df[["revenue", "units", "customer_satisfaction"]]
correlation_matrix = numeric_cols.corr()
fig, ax = plt.subplots(figsize=(7, 5))
sns.heatmap(
correlation_matrix,
annot=True, # Show the numbers inside each cell
fmt=".2f", # Format to 2 decimal places
cmap="coolwarm", # Red = positive, Blue = negative
center=0, # Center the colormap at zero
square=True,
linewidths=0.5,
cbar_kws={"shrink": 0.8},
ax=ax
)
ax.set_title("Correlation Matrix", fontsize=13, fontweight="bold", pad=15)
plt.tight_layout()
plt.show()
This is the visual equivalent of a pivot table — you can spot which region/category combinations are strongest at a glance:
pivot_data = df.pivot_table(
index="region",
columns="category",
values="revenue",
aggfunc="sum"
)
fig, ax = plt.subplots(figsize=(9, 5))
sns.heatmap(
pivot_data,
annot=True,
fmt=",.0f",
cmap="YlOrRd",
linewidths=0.5,
ax=ax
)
ax.set_title("Total Revenue by Region and Category ($)", fontsize=13, fontweight="bold", pad=15)
ax.set_xlabel("Category", fontsize=11)
ax.set_ylabel("Region", fontsize=11)
plt.tight_layout()
plt.show()
The real power of matplotlib's Figure/Axes model shows up when you combine charts. Here's a four-panel summary dashboard — the kind of thing you'd put at the top of a report or a Jupyter notebook deliverable.
fig = plt.figure(figsize=(14, 10))
fig.suptitle("Sales Analysis Dashboard — 2023", fontsize=16, fontweight="bold", y=1.01)
# --- Panel 1: Revenue by Region (top-left) ---
ax1 = fig.add_subplot(2, 2, 1)
rev_region = df.groupby("region")["revenue"].sum().sort_values(ascending=False)
ax1.bar(rev_region.index, rev_region.values, color="#2563EB", edgecolor="white")
ax1.set_title("Revenue by Region", fontsize=12, fontweight="bold")
ax1.set_ylabel("Revenue ($)")
ax1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x/1e6:.1f}M"))
ax1.spines["top"].set_visible(False)
ax1.spines["right"].set_visible(False)
# --- Panel 2: Monthly Revenue Trend (top-right) ---
ax2 = fig.add_subplot(2, 2, 2)
monthly = df.groupby("order_date")["revenue"].sum().sort_index()
ax2.plot(monthly.index, monthly.values, color="#2563EB", linewidth=2.5, marker="o", markersize=5)
ax2.set_title("Monthly Revenue Trend", fontsize=12, fontweight="bold")
ax2.set_ylabel("Revenue ($)")
ax2.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x/1e3:.0f}K"))
ax2.spines["top"].set_visible(False)
ax2.spines["right"].set_visible(False)
fig.autofmt_xdate()
# --- Panel 3: Revenue Distribution (bottom-left) ---
ax3 = fig.add_subplot(2, 2, 3)
ax3.hist(df["revenue"], bins=35, color="#2563EB", edgecolor="white", alpha=0.8)
ax3.axvline(df["revenue"].median(), color="#DC2626", linestyle="--", linewidth=2,
label=f"Median: ${df['revenue'].median():,.0f}")
ax3.set_title("Revenue Distribution", fontsize=12, fontweight="bold")
ax3.set_xlabel("Revenue ($)")
ax3.set_ylabel("Order Count")
ax3.legend(fontsize=9)
ax3.spines["top"].set_visible(False)
ax3.spines["right"].set_visible(False)
# --- Panel 4: Category Box Plots (bottom-right) ---
ax4 = fig.add_subplot(2, 2, 4)
sns.boxplot(data=df, x="category", y="revenue", palette="Set2", linewidth=1.2,
flierprops={"marker": "o", "markersize": 3, "alpha": 0.4}, ax=ax4)
ax4.set_title("Revenue by Category", fontsize=12, fontweight="bold")
ax4.set_xlabel("Category")
ax4.set_ylabel("Revenue ($)")
ax4.spines["top"].set_visible(False)
ax4.spines["right"].set_visible(False)
ax4.tick_params(axis="x", rotation=15)
plt.tight_layout()
plt.savefig("sales_dashboard.png", dpi=150, bbox_inches="tight")
plt.show()
The plt.savefig() call at the end is how you export to a file — dpi=150 gives you a sharp image, and bbox_inches="tight" ensures nothing gets cropped.
Tip
Use fig.add_subplot(rows, cols, index) for precise control over subplot positions, or plt.subplots(2, 2) when you want a regular grid. For irregular layouts (one wide chart spanning two columns), look into fig.add_gridspec() — it gives you CSS grid-level control over subplot sizing.
When you have more categories than a single chart can handle cleanly, small multiples — one panel per category — are often cleaner than overlapping lines or a cluttered grouped bar chart.
g = sns.FacetGrid(df, col="category", col_wrap=2, height=4, aspect=1.4, sharey=False)
g.map_dataframe(sns.histplot, x="revenue", bins=25, color="#2563EB", alpha=0.8)
g.set_titles(col_template="{col_name}", fontsize=12, fontweight="bold")
g.set_axis_labels("Revenue ($)", "Order Count")
for ax in g.axes.flat:
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x/1e3:.0f}K"))
g.figure.suptitle("Revenue Distribution by Category", fontsize=14, fontweight="bold", y=1.02)
plt.tight_layout()
plt.show()
sharey=False lets each panel scale independently — important when categories have very different volumes and you want to see each distribution's shape rather than compare absolute counts.
Note
seaborn FacetGrid works best when your data is in long format (one row per observation), which is how well-structured pandas DataFrames are organized. If your data is wide (one column per category), use melt to reshape it before passing it to seaborn.
A chart that communicates well looks intentional, not accidental. A few consistent habits will raise your charts from "default matplotlib" to something you're comfortable putting in a report.
# Put this near the top of your notebook, before any charts
sns.set_theme(style="whitegrid", palette="Set2", font="sans-serif")
# Or use a matplotlib style
plt.style.use("seaborn-v0_8-whitegrid")
seaborn's built-in styles: whitegrid, darkgrid, white, dark, ticks. For reports, white or ticks with clean spines tends to look the most professional.
#2563EB or similar) for neutral charts#DC2626) sparingly to highlight something importantYlOrRd or BluesRdBu or coolwarmSet2 (soft) or Set1 (bold)fig, ax = plt.subplots(figsize=(9, 5))
monthly = df.groupby("order_date")["revenue"].sum().sort_index()
ax.plot(monthly.index, monthly.values, color="#2563EB", linewidth=2.5, marker="o", markersize=5)
# Find and annotate the peak month
peak_month = monthly.idxmax()
peak_value = monthly.max()
ax.annotate(
f"Peak: ${peak_value:,.0f}",
xy=(peak_month, peak_value),
xytext=(peak_month, peak_value * 1.08),
fontsize=10,
fontweight="bold",
color="#DC2626",
arrowprops={"arrowstyle": "->", "color": "#DC2626"},
ha="center"
)
ax.set_title("Monthly Revenue with Peak Annotated", fontsize=13, fontweight="bold", pad=15)
ax.set_xlabel("Month")
ax.set_ylabel("Revenue ($)")
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
fig.autofmt_xdate(rotation=45)
plt.tight_layout()
plt.show()
Annotation is the bridge between a chart and a conclusion. When you annotate the peak month, you're telling the reader what to notice. That's analysis, not just visualization.
Build a complete visual analysis of the sales dataset with the following requirements. All four charts should appear in a single saved figure.
Your task:
Horizontal bar chart — Top 10 sales reps by total revenue. Use a horizontal bar chart (ax.barh()) so the rep names are readable. Sort bars so the highest revenue rep is at the top.
Stacked line chart — Monthly revenue split by region. Plot four lines (one per region) on the same axes with a clear legend.
Scatter plot — Customer satisfaction (x) vs. average revenue per order (y), aggregated at the sales rep level. Each point represents one sales rep. Add rep labels for the top 5 by revenue.
Heatmap — Average customer satisfaction by region and category (use pivot_table with aggfunc="mean").
Stretch goal: Add a color-coding to the scatter plot where each point's color represents which region the rep's most common orders came from.
This exercise covers groupby aggregation, reshaping data for the heatmap, and all the visualization techniques from this lesson. Work through it without copying the lesson code directly — the struggle is where the learning happens.
Problem: My seaborn chart appears in a separate figure and I can't customize it
Always pass ax=ax to seaborn functions. Without it, seaborn creates its own Figure and ignores your layout.
Problem: Subplot labels are overlapping
Call plt.tight_layout() before plt.show(). For suptitles that still overlap, add plt.tight_layout(rect=[0, 0, 1, 0.96]) to leave space at the top.
Problem: x-axis date labels are crammed together
Call fig.autofmt_xdate(rotation=45) or ax.tick_params(axis="x", rotation=45). Also consider formatting dates more compactly with ax.xaxis.set_major_formatter(mdates.DateFormatter("%b")).
Problem: My saved figure is blurry
Increase dpi in plt.savefig(). Use dpi=150 for web, dpi=300 for print. Also ensure bbox_inches="tight" to prevent cropping.
Problem: seaborn barplot is showing error bars I don't want
In newer seaborn versions (0.12+), barplot shows confidence intervals by default. Add errorbar=None to disable: sns.barplot(..., errorbar=None).
Problem: My colors look wrong or inconsistent across charts
Set your palette once with sns.set_palette("Set2") at the top of your notebook. For matplotlib charts, define a list: colors = sns.color_palette("Set2", n_colors=4) and index into it.
Problem: Heatmap annotations are showing scientific notation
Use fmt=",.0f" for integers or fmt=".2f" for decimals. If numbers are very large, pre-divide the matrix values before plotting: pivot_data / 1000 and adjust your title to note the unit.
Warning
Don't use pie charts for more than 4-5 categories, and even then, ask yourself if a sorted bar chart would be clearer. The human eye is very good at comparing lengths and very bad at comparing angles and areas. Pie charts are almost always the wrong choice for data analysis — they're better suited to presentations where you want to make a single proportional point (e.g., "Software accounts for more than half of revenue").
You've now got a complete visualization toolkit:
The pattern across every chart is the same: aggregate your data with pandas, then hand off a clean, properly shaped DataFrame to the chart function. The better your data preparation — cleaning it thoroughly, loading it correctly — the less friction you'll hit at the visualization stage.
The natural next step from here is automating these charts — generating a report that runs weekly, saves figures to files, and emails them or drops them into a shared folder. That's where Python's advantage over Excel really compounds: your visualization code runs on fresh data with zero manual effort.