Data Visualization with Matplotlib and Seaborn
A number in a table hides; a picture reveals. This chapter is practical plotting: Matplotlib's figure/axes model (the foundation everything sits on), Seaborn's statistical charts straight from a DataFrame, and — crucially — how to choose the right chart and not lie with it.
Learning objectives
- Explain Matplotlib's Figure/Axes model and the two API styles.
- Make the core chart types and know which question each answers.
- Use Seaborn to plot statistical charts directly from a DataFrame.
- Choose the right chart for the data — and avoid misleading ones.
- Save/return charts for reports, apps, and LLM pipelines.
groupby, not raw mess. Matplotlib underlies almost all Python plotting (including Seaborn and pandas' own .plot()), and it's pre-installed in the code-execution sandbox (C2/tool-use), so agents use it to generate charts too.The Matplotlib mental model: Figure & Axes essential
Almost all Matplotlib confusion comes from missing one idea. A Figure is the whole canvas; an Axes is a single plot on it (with its own x/y axis). You draw on Axes. A figure can hold several Axes (subplots).
fig, ax = plt.subplots() and then call methods on ax — ax.plot(), ax.bar(), ax.set_title(). Grasp this and Matplotlib stops feeling random; miss it and you'll fight the API forever.
This is the one idea that makes Matplotlib click. A Figure is the whole canvas (the sheet of paper); an Axes is a single plot drawn on it, with its own x-axis and y-axis. "Axes" is confusing at first — it does not mean the x/y lines; it means one whole chart.
- The big outer box is the Figure — the container. One figure can hold several plots.
- Each inner box is an Axes — one individual chart. Here the figure holds two: Axes 1 (drawn with a line) and Axes 2 (drawn with bars).
- The little axis lines and the line/bars inside each box are what you actually draw. You put them there by calling methods on that Axes — the caption shows
ax.plot(...)andax.set_title(...). - The normal way to get both at once is
fig, ax = plt.subplots():figis the canvas,axis the one plot on it.
In short: Figure = the page, Axes = one chart on the page. You spend almost all your time calling ax.something(...) to draw on a single Axes.
Two API styles — pick the explicit one essential
| pyplot (state machine) | Object-oriented (explicit) | |
|---|---|---|
| Looks like | plt.plot(x, y); plt.title(...) | fig, ax = plt.subplots(); ax.plot(x, y) |
| Draws on | "current" axes (implicit) | the ax you hold (explicit) |
| Fine for | quick one-off plots in a notebook | everything real — subplots, apps, functions |
fig, ax = plt.subplots()The plt.-everything style relies on a hidden "current axes" and breaks down the moment you have multiple plots or return a figure from a function. The explicit object-oriented style — hold fig and ax, call methods on them — is unambiguous and scales. Learn it first; it's the same effort and saves confusion later.Lab B2.1 · Core charts in Matplotlib essential
Requires: pip install matplotlib
charts.pyimport matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(months, revenue, marker="o") # line: trend over time
ax.set_title("Monthly revenue")
ax.set_xlabel("month"); ax.set_ylabel("$")
fig.savefig("revenue.png", dpi=150, bbox_inches="tight")
# subplots: two Axes on one Figure
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
ax1.bar(regions, totals) # bar: compare categories
ax2.hist(latencies, bins=30) # histogram: see a distribution
This is the core Matplotlib pattern you'll reuse everywhere: make a Figure and Axes, draw on the Axes, label it, then save it. The second half shows how to put two plots side by side on one figure.
fig, ax = plt.subplots(figsize=(7, 4))creates the canvas (fig) and one plot (ax) on it.figsizeis the size in inches (width, height).ax.plot(months, revenue, marker="o")draws a line chart — x-valuesmonthsagainst y-valuesrevenue, with a dot at each point. A line is the right choice for a trend over time.ax.set_title(...),ax.set_xlabel(...), andax.set_ylabel(...)add the title and axis labels. Always label — an unlabeled chart can mean anything.fig.savefig("revenue.png", dpi=150, bbox_inches="tight")writes the picture to a file.dpisets sharpness;bbox_inches="tight"trims extra whitespace around the edges.- The second block asks for a grid of plots:
plt.subplots(1, 2, ...)means 1 row, 2 columns, so you get back two Axes —ax1gets a bar chart (ax1.bar, to compare categories) andax2gets a histogram (ax2.hist(..., bins=30), to see a distribution).
What the output means: Two PNG-able figures: a labelled revenue line chart saved as revenue.png, and a second figure showing a bar chart and a histogram side by side.
Try this: Change marker="o" to marker="s" (squares) or drop it entirely, and change bins=30 to bins=5 on the histogram — fewer bins means chunkier bars and a coarser view of the distribution.
.plot() built in (it uses Matplotlib): by_region.plot.bar(ax=ax). That's the fastest path from a B1 groupby to a chart — pass it the ax you made so you keep control of the figure.Lab B2.2 · Seaborn: statistical charts from a DataFrame intermediate
Seaborn sits on Matplotlib and is built for DataFrames: you pass the frame plus column names, and it handles the statistics and styling. It's the fast path for the charts analysts actually make.
Requires: pip install seaborn
seaborn_charts.pyimport seaborn as sns
sns.barplot(data=df, x="region", y="total") # aggregates + error bars for you
sns.histplot(data=df, x="latency", bins=30) # distribution
sns.scatterplot(data=df, x="price", y="qty", hue="region") # relationship, colored by group
sns.boxplot(data=df, x="region", y="total") # compare distributions across groups
sns.heatmap(df.corr(numeric_only=True), annot=True) # correlations between numeric columns
Seaborn is built for DataFrames: instead of handing it arrays, you hand it the whole frame and the names of the columns to use. It then does the grouping, the math, and the styling for you. Every line here is a different chart type from the same df.
- The shared shape is
sns.SOMECHART(data=df, x="col", y="col")— pass the DataFrame, then name the columns for the x and y axes. sns.barplot(..., x="region", y="total")groups by region and shows the average total per region, with little error bars for the spread — all computed automatically.sns.histplot(..., x="latency", bins=30)shows the distribution of one column;sns.scatterplot(..., x="price", y="qty")plots each row as a dot to reveal a relationship between two numbers.hue="region"is the superpower: it splits the chart by category using color, turning one plot into a comparison across groups.sns.boxplot(...)compares the shape of a distribution across groups, andsns.heatmap(df.corr(numeric_only=True), annot=True)draws a colored grid of how strongly the numeric columns correlate (annot=Trueprints the number in each cell).
What the output means: Five statistical charts drawn straight from df — bar, histogram, scatter, box, and correlation heatmap — with grouping and aggregation handled for you.
Try this: Add hue="region" to the histplot line and watch it split into one colored distribution per region. Then remove annot=True from the heatmap to see the grid without the printed numbers.
data=df, x="col", y="col" — and it does the grouping, aggregation, and error bars for you. The hue="group" parameter is a superpower: it splits any chart by a category with color, turning one plot into a comparison. Reach for Seaborn for statistical/exploratory charts; drop to Matplotlib when you need pixel control.Choosing the right chart intermediate
The hardest part isn't the code — it's picking a chart that answers your question honestly. Match the chart to the question.
| Your question | Chart | Why |
|---|---|---|
| How does X change over time? | Line | Connects ordered points; shows trend |
| How do categories compare? | Bar | Length is easy to compare across groups |
| What's the distribution of X? | Histogram / box / violin | Shows spread, skew, outliers |
| Is X related to Y? | Scatter | Reveals correlation/clusters |
| How do many variables correlate? | Heatmap | Matrix of pairwise relationships |
| Parts of a whole (few, sum to 100%) | Bar (usually) not pie | Humans compare lengths better than angles |
Lab B2.3 · Not lying with charts intermediate
A chart is an argument. Small choices can mislead — sometimes accidentally. Professional plotting means making the honest version.
This picture shows the single most common way a chart lies: where the y-axis starts. Both sides plot the exact same two numbers — only the axis differs — yet they tell opposite stories.
- The left chart (✗) has a y-axis that starts at 90, not 0. That chops off the bottom of the bars, so a tiny 2% difference in the numbers looks like a huge gap.
- The right chart (✓) has a y-axis that starts at 0. Now you see the full height of each bar, and the real difference — small — is obvious.
- The lesson: for bar charts, the bar's length is how the reader judges the value, so starting anywhere but zero exaggerates. Truncated axes, cherry-picked ranges, and forced dual axes are the classic tricks.
In short: Same data, opposite impression. For bars, start the y-axis at zero and label everything — that's the honest default.
| Honest-charting rule | Why |
|---|---|
| Start bar-chart y-axes at zero | Bar length encodes value; a truncated axis exaggerates |
| Label axes, units, and the title | An unlabeled chart can mean anything |
| Don't over-plot / rainbow everything | Clutter hides the signal; use color with purpose |
| Show the data's real shape | Distribution over a lone average when spread matters |
| One clear message per chart | A chart trying to say five things says nothing |
Lab B2.4 · Charts for reports, apps & LLM pipelines advanced
Where visualization plugs into the rest of the course: charts get saved for reports, returned to a web app (B4), or generated by an agent in the code sandbox.
Illustrative fragment — defines demo values / files are needed before this runs standalone.
deliver.py# save to a file (reports, email, artifacts)
fig.savefig("report.png", dpi=150, bbox_inches="tight")
# return the figure object to a web framework (Streamlit/Gradio, B4)
def make_chart(df) -> "plt.Figure":
fig, ax = plt.subplots()
sns.barplot(data=df, x="region", y="total", ax=ax)
return fig # st.pyplot(fig) / Gradio Plot component renders it
# in an agent's code-execution tool, the generated PNG comes back as a file (C2)
The point of a chart is to deliver it somewhere: a saved image for a report, or a live figure handed to a web app or an agent. This fragment shows both — and why returning a figure (not calling plt.show()) is the shape the rest of the course needs.
fig.savefig("report.png", dpi=150, bbox_inches="tight")writes the figure to a file — the way you attach a chart to a report or an email.def make_chart(df) -> "plt.Figure":is a function that builds a chart and hands it back. Inside it makes its ownfig, ax, draws a Seaborn bar plot onto thatax, and thenreturn fig— it returns the figure object instead of showing it.- Passing
ax=axtosns.barplottells Seaborn to draw on the exact Axes you created, so the function stays in control of its figure. - Returning the figure lets a caller decide what to do with it: a web framework shows it (
st.pyplot(fig)in Streamlit, a Plot component in Gradio, B4), or an agent's code sandbox turns it into a PNG file (C2).
What the output means: A reusable make_chart(df) that returns a Figure you can either save to disk or hand to an app/agent to display — no plt.show() needed.
Try this: Never put plt.show() inside a function like this — it tries to pop up a window and blocks apps and agents. Build the figure, return fig, and let the caller display or save it.
Common pitfalls advanced
| Pitfall | Fix |
|---|---|
Fighting the plt. state machine | Use fig, ax = plt.subplots() and call methods on ax |
| Truncated bar-chart axes | Start at zero; don't exaggerate differences |
| Unlabeled axes/title/units | Label everything — a chart must stand alone |
| Bar of means hiding the distribution | Use histogram/box/violin when shape matters |
| Pie chart with many slices | Prefer a bar chart; lengths beat angles |
| Passing raw messy data to a plot | Clean/aggregate in Pandas first (B1) |
| Reaching for Matplotlib for a stat chart | Seaborn from the DataFrame is faster & cleaner |
Exercises advanced
Exercise B2.1 — Chart a groupby
Context: A group-by result from the data lesson is only an answer once someone can see it. Three views of the same summary — a comparison, a trend, and a statistical chart — cover most of what a report needs.
Your task: Take a group-by result and make three charts of it with the explicit fig, ax style — a bar, a line, and one Seaborn statistical chart — labelling every axis and title, and save one as a PNG.
Requirements:
- Reuse a real group-by result as the data
- Make three charts with the explicit Figure/Axes API: a bar, a line, and a Seaborn statistical chart
- Give every chart a clear title and label both axes
- Save at least one chart as a PNG file
💡 Hint: Match each chart to its job — bar to compare groups, line for a trend, Seaborn for the statistical view — and drive them all through ax.
Exercise B2.2 — Fix a misleading chart
Context: The judgment that separates a chart from a lie is seeing how the same data can read two ways. Building the dishonest version on purpose, then the honest one, makes the difference impossible to un-see.
Your task: Deliberately make a misleading chart (a truncated y-axis, or a bar of means over bimodal data), then make the honest version and write one sentence on how the reader's takeaway changed.
Requirements:
- Build the misleading chart first (truncated axis, or a mean-bar over split data)
- Build the honest counterpart from the same data
- Only the framing changes — the underlying numbers are identical
- Write one sentence on what the reader now correctly sees
- Note which specific choice (zero baseline, histogram) fixed the distortion
💡 Hint: For a truncated axis the fix is a zero baseline; for a mean-bar over bimodal data the fix is a histogram that reveals the two clusters.
Show what to look for
The truncated-axis version makes a trivial difference look dramatic; the zero-based version shows it's small. The mean-bar hides that the data is split; the histogram reveals two clusters. In both, the data is identical — only the honest chart lets the reader see what's true.
Exercise B2.3 — Return a Figure
Context: Apps and agents don't want a chart splashed to the screen — they want a Figure object they can save or hand to a display function. Returning the figure instead of calling show() is the shape the rest of the module depends on.
Your task: Write a function make_chart(df) -> Figure that builds and returns a matplotlib Figure without calling plt.show() inside it, then confirm you can both save it and pass the returned figure to a display function.
Requirements:
- The function returns a
Figureobject - It does not call
plt.show()internally - The returned figure can be saved with
savefig - The same returned figure can be handed to a display function (e.g.
st.pyplot)
💡 Hint: Build the figure with fig, ax = plt.subplots() and simply return fig — let the caller decide whether to save or display it.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The plt.plot global-state style works until you need two charts in one figure, then it fights you. The explicit Figure/Axes API scales cleanly and never depends on hidden state — it's the habit worth building first.
Your task: Using the explicit fig, ax = plt.subplots() API, plot monthly values as a line chart with a title and labels on both axes.
Requirements:
- Create the figure and axes explicitly with
plt.subplots(), notplt.plotglobal state - Plot the monthly series on the axes (a marker helps read the points)
- Set a title and label both the x and y axes
- Render or save the figure (e.g.
fig.savefig(...)orplt.show())
💡 Hint: Everything hangs off the ax object — ax.plot, ax.set_title, ax.set_xlabel — so the chart never relies on which plot was 'current'.
Show solution
Needs matplotlib + a display/file. Prefer the explicit fig, ax API — it scales to
subplots and never depends on hidden global state:
import matplotlib.pyplot as plt
months = ["Jan", "Feb", "Mar", "Apr"]
values = [10, 14, 9, 18]
fig, ax = plt.subplots()
ax.plot(months, values, marker="o")
ax.set_title("Monthly active users")
ax.set_xlabel("Month")
ax.set_ylabel("Users (thousands)")
fig.savefig("mau.png", dpi=120, bbox_inches="tight") # or plt.show()
Always label axes and title — an unlabeled chart isn’t evidence, it’s decoration.
Context: Choosing the wrong chart type quietly misleads even with correct data. The chart should be dictated by the question you're answering, not by what looks nice.
Your task: For three situations — revenue share across five product lines, a distribution of request latencies, and a year of daily signups — name the right chart for each and say why, then show sample code for one.
Requirements:
- Revenue share across categories → a bar chart, because lengths are easy to compare (not a pie)
- Distribution of latencies → a histogram (or box/violin) to show spread and outliers a mean hides
- Trend over a year → a line chart with time on the x-axis
- Give a one-line justification tying each choice to comparison / distribution / trend
- Provide runnable sample code for at least one (e.g. the bar chart)
💡 Hint: Map the question to the chart: comparison wants bars, spread wants a histogram, change-over-time wants a line.
Show solution
- (a) Share across categories: a bar chart (not a pie) — bars let the eye compare lengths precisely; pies are hard to read past 3–4 slices.
- (b) Distribution: a histogram (or box/violin) — you want to see spread, skew, and outliers, which a single mean hides.
- (c) Trend over time: a line chart — time on x, value on y, continuity shows the trend.
Needs matplotlib + display for the bar example:
import matplotlib.pyplot as plt
lines = ["A", "B", "C", "D", "E"]; rev = [30, 22, 18, 15, 5]
fig, ax = plt.subplots()
ax.bar(lines, rev)
ax.set_ylabel("Revenue share (%)"); ax.set_title("Revenue by product line")
plt.show()
Match the chart to the question: comparison → bars, distribution → histogram, trend → line.
Context: Seaborn maps DataFrame columns straight onto statistical charts, so you can see a distribution per category in a couple of lines — and a box plot exposes the outlier that a bar-of-means would bury.
Your task: Use Seaborn to draw a box plot of a numeric metric across a category directly from a tidy DataFrame — for example latency by endpoint.
Requirements:
- Feed a tidy DataFrame to Seaborn, mapping the category to x and the metric to y
- Use a box plot (
sns.boxplot) so spread and outliers are visible - Give the chart a descriptive title
- Render the figure (needs seaborn + matplotlib + a display)
- Confirm the box plot surfaces an outlier that a mean would hide
💡 Hint: Pass data=df, x=..., y=... straight to sns.boxplot — Seaborn handles the aggregation and drawing from the column names.
Show solution
Needs seaborn + matplotlib + a display. Seaborn maps DataFrame columns straight to a statistical chart:
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
df = pd.DataFrame({
"endpoint": ["/a", "/a", "/a", "/b", "/b", "/b"],
"latency": [120, 130, 400, 90, 95, 88],
})
ax = sns.boxplot(data=df, x="endpoint", y="latency")
ax.set_title("Latency distribution by endpoint")
plt.show()
The box plot exposes the /a outlier (400ms) that a bar-of-means would hide — the point of a
distributional view.
Context: Two of the most common ways a chart misleads are a truncated y-axis that exaggerates tiny differences and dual axes that imply a correlation that isn't there. Knowing the honest fix is the judgment that separates a chart from a lie.
Your task: Explain the truncated-y-axis and dual-axis pitfalls, then show the honest fix in code — a bar chart whose y-axis starts at zero so a small gap looks small.
Requirements:
- Explain why a truncated y-axis exaggerates small differences on a bar chart
- Explain why dual y-axes can imply a false correlation between unrelated series
- Show a bar chart with the y-axis baseline at 0 (
ax.set_ylim(0, ...)) - Contrast it with the misleading truncated version in a comment
- State the rule: bar length encodes value, so the axis must start at 0
💡 Hint: For bars the length is the value, so setting a zero baseline is the fix; note what the misleading set_ylim would have been.
Show solution
Truncated y-axis exaggerates small differences; start bar-chart axes at 0. Dual y-axes can make two unrelated series look correlated — avoid unless the reader clearly understands both scales.
Needs matplotlib + a display. Honest bar chart with a zero baseline:
import matplotlib.pyplot as plt
labels = ["Q1", "Q2", "Q3"]; vals = [98, 99, 100]
fig, ax = plt.subplots()
ax.bar(labels, vals)
ax.set_ylim(0, 110) # honest: baseline at 0, so 98 vs 100 looks like ~2%
ax.set_title("Quarterly score (true scale)")
plt.show()
# Misleading version would be: ax.set_ylim(97, 100) -> a 2% gap looks huge
The rule: for bars, the length encodes the value, so the axis must start at 0 or you distort the comparison.
Context: A figure destined for a report needs more than a plot — it needs multiple panels in one image, a shared title, and print-resolution output that doesn't clip its own labels. That polish is the line between a draft and something you paste in.
Your task: Produce a report-ready figure with two subplots — a trend and a distribution — sharing one overall title, saved to a file at print resolution.
Requirements:
- Create a single figure with two subplots via
plt.subplots(1, 2, ...) - Configure each axes independently (one line/trend, one histogram)
- Add a shared overall title with
fig.suptitle(...) - Save to a file at print resolution (a higher
dpi) withbbox_inches="tight" - Use
tight_layout()so labels don't clip
💡 Hint: subplots(1, 2) hands back an array of axes you configure separately, then tight_layout plus bbox_inches="tight" keep the saved image clean.
Show solution
Needs matplotlib + file output. subplots returns an array of axes you configure
independently, then save the whole figure:
import matplotlib.pyplot as plt
months = ["Jan", "Feb", "Mar", "Apr"]; signups = [10, 14, 9, 18]
latencies = [120, 130, 400, 90, 95, 88, 110, 100]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
ax1.plot(months, signups, marker="o"); ax1.set_title("Signups"); ax1.set_xlabel("Month")
ax2.hist(latencies, bins=6); ax2.set_title("Latency distribution"); ax2.set_xlabel("ms")
fig.suptitle("Weekly report", fontweight="bold")
fig.tight_layout()
fig.savefig("report.png", dpi=150, bbox_inches="tight")
tight_layout() + bbox_inches="tight" stop labels from clipping — the difference
between a draft and something you paste into a doc.
Context: An automated report pipeline needs a chart function it can call repeatedly in a headless job without a display — and without leaking a figure into memory on every run. That means the Agg backend and disciplined cleanup.
Your task: Wrap chart creation in a function that takes a DataFrame plus column names, writes a PNG, and depends on no global pyplot state so an automated report pipeline can call it in a loop.
Requirements:
- Select a headless backend (
matplotlib.use("Agg")) so no display is needed - The function takes the DataFrame, the category and value column names, and an output path
- Create a fresh
fig, axper call — no reliance on global pyplot state - Label the axes and write the PNG to the given path
- Close the figure (
plt.close(fig)) so a long-running job doesn't leak memory, and return the path
💡 Hint: The Agg backend plus a per-call figure and plt.close is exactly what makes matplotlib safe inside a repeated, headless report job.
Show solution
Needs matplotlib. Create a fresh Figure per call (no global state), and close it so
a long-running pipeline doesn’t leak figures:
import matplotlib
matplotlib.use("Agg") # headless backend: no display needed in a pipeline
import matplotlib.pyplot as plt
def save_bar(df, cat_col, val_col, out_path, title=""):
# Write a bar chart of val_col by cat_col to out_path (PNG). Returns out_path.
fig, ax = plt.subplots()
ax.bar(df[cat_col].astype(str), df[val_col])
ax.set_xlabel(cat_col); ax.set_ylabel(val_col); ax.set_title(title)
fig.tight_layout()
fig.savefig(out_path, dpi=120, bbox_inches="tight")
plt.close(fig) # release memory in a batch job
return out_path
# usage (in a pipeline):
# import pandas as pd
# df = pd.DataFrame({"region": ["US","EU"], "rev": [250,120]})
# save_bar(df, "region", "rev", "rev.png", "Revenue by region")
Agg backend + plt.close are what make matplotlib safe inside a headless, repeated
report job.
✓ Checkpoint — you can move on when you can…
- Explain Figure vs Axes and use the explicit
fig, axstyle. - Make line, bar, histogram, and scatter charts.
- Use Seaborn to plot statistical charts straight from a DataFrame.
- Pick the right chart for a question and avoid misleading ones.
- Save a chart and return a Figure for an app or agent.
Knowledge check check yourself
In Matplotlib, what is the difference between a Figure and an Axes, and which do you draw on?
Show answer
ax.plot() and ax.set_title(), usually obtained via fig, ax = plt.subplots().Why should bar-chart y-axes start at zero, and what does a truncated axis do to the reader?