Matplotlib Basics¶
Matplotlib is the foundation of Python data visualization. Every other plotting library — seaborn, pandas .plot(), plotly — either wraps or imitates it. Understanding matplotlib's object model means you can customize any chart from any library, because they all ultimately render through matplotlib's Figure and Axes system.
Learning Objectives¶
- Understand the Figure/Axes object model and why it matters
- Create line, bar, scatter, histogram, and box charts
- Add titles, labels, legends, and annotations
- Build multi-panel figures with
plt.subplots()andgridspec - Style charts: spines, grids, colors, formatters
- Save charts to file with the correct resolution
The Object Model — Figure and Axes¶
The single most important mental model in matplotlib:
- Figure (
fig): the entire canvas — like a blank piece of paper - Axes (
ax): a single plot area on that canvas — where data is drawn - One Figure can contain many Axes
import matplotlib.pyplot as plt
import numpy as np
# The professional approach: explicit Figure and Axes handles
fig, ax = plt.subplots(figsize=(10, 5))
# fig → canvas (controls size, background, overall title)
# ax → plot area (controls data, labels, ticks, title)
ax.plot([1, 2, 3, 4], [10, 20, 15, 30])
ax.set_title("My Chart")
ax.set_xlabel("X Axis")
ax.set_ylabel("Y Axis")
plt.tight_layout() # adjust layout so labels don't clip
plt.show()
plt.figure() vs fig, ax = plt.subplots()
plt.plot() works but uses an implicit "current axes" concept that breaks in loops and multi-panel layouts. Always use fig, ax = plt.subplots() so you have explicit handles to customize each element. This is the industry-standard approach.
For mid-level analysts
`figsize=(width, height)` is in inches at 100 dpi by default. A standard slide is 10×5.6 (16:9 ratio). For presentations, use `figsize=(12, 6.75)`. For portrait reports, `figsize=(8, 10)`.
Styling Axes — The Professional Touch¶
Default matplotlib axes have heavy spines and tick marks that add visual noise. Clean them up immediately.
fig, ax = plt.subplots(figsize=(10, 5))
# Remove top and right spines (redundant borders)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
# Make remaining spines lighter
ax.spines["left"].set_color("#CCCCCC")
ax.spines["bottom"].set_color("#CCCCCC")
# Remove tick marks (the little lines on axes)
ax.tick_params(left=False, bottom=False)
# Subtle grid instead of harsh gridlines
ax.grid(axis="y", alpha=0.3, linestyle="--", color="#CCCCCC")
# Or apply a clean preset style
plt.style.use("seaborn-v0_8-whitegrid")
Line Chart — Trends Over Time¶
Use when: you want to show how a value changes over an ordered sequence (time, progression).
import pandas as pd
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
revenue = [42000, 38500, 51200, 49800, 55100, 48250]
target = [45000, 45000, 48000, 48000, 52000, 52000]
fig, ax = plt.subplots(figsize=(11, 5))
# Actual revenue line
ax.plot(months, revenue,
marker="o", color="#0D9488", linewidth=2.5,
label="Actual Revenue", zorder=3)
# Target line (dashed, secondary color)
ax.plot(months, target,
linestyle="--", color="#F59E0B", linewidth=1.5,
label="Target", zorder=2)
# Shade below-target gaps
ax.fill_between(
months, revenue, target,
where=[r < t for r, t in zip(revenue, target)],
alpha=0.12, color="#EF4444", label="Below target"
)
# Formatting
ax.set_title("Monthly Revenue vs Target — H1 2024",
fontsize=14, fontweight="bold", pad=15)
ax.set_xlabel("Month")
ax.set_ylabel("Revenue (£)")
# Format y-axis as currency
ax.yaxis.set_major_formatter(
plt.FuncFormatter(lambda x, _: f"£{x:,.0f}")
)
ax.set_ylim(0, max(max(revenue), max(target)) * 1.15)
ax.legend(framealpha=0.9)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.savefig("revenue_trend.png", dpi=150, bbox_inches="tight")
plt.show()
# Expected output: line chart with teal actual line, amber dashed target line,
# red-shaded areas in Feb where actual < target
Bar Chart — Comparing Categories¶
Use when: comparing values across a small number of discrete categories.
categories = ["Electronics", "Furniture", "Books", "Sports", "Kitchen"]
revenue = [28500, 18200, 12300, 8900, 15600]
# Highlight the top category; grey the rest
colors = ["#0D9488" if i == 0 else "#CBD5E1" for i in range(len(categories))]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 5))
# --- Vertical bar chart ---
bars = ax1.bar(categories, revenue, color=colors, edgecolor="white", linewidth=0.5)
ax1.set_title("Electronics Leads Revenue by Category", fontweight="bold")
ax1.set_ylabel("Revenue (£)")
ax1.tick_params(axis="x", rotation=20)
ax1.set_ylim(0, max(revenue) * 1.18)
ax1.spines["top"].set_visible(False)
ax1.spines["right"].set_visible(False)
ax1.grid(axis="y", alpha=0.3)
# Add value labels on each bar
for bar in bars:
h = bar.get_height()
ax1.text(
bar.get_x() + bar.get_width() / 2., h + 300,
f"£{h/1000:.0f}k",
ha="center", va="bottom", fontsize=9, color="#374151"
)
# --- Horizontal bar chart (better for many categories or long names) ---
sorted_idx = sorted(range(len(revenue)), key=lambda i: revenue[i])
sorted_cats = [categories[i] for i in sorted_idx]
sorted_rev = [revenue[i] for i in sorted_idx]
h_colors = ["#0D9488" if c == "Electronics" else "#CBD5E1" for c in sorted_cats]
ax2.barh(sorted_cats, sorted_rev, color=h_colors, edgecolor="white")
ax2.set_title("Revenue by Category (Ranked)", fontweight="bold")
ax2.set_xlabel("Revenue (£)")
ax2.spines["top"].set_visible(False)
ax2.spines["right"].set_visible(False)
ax2.grid(axis="x", alpha=0.3)
for i, (cat, val) in enumerate(zip(sorted_cats, sorted_rev)):
ax2.text(val + 300, i, f"£{val/1000:.0f}k", va="center", fontsize=9)
plt.tight_layout()
plt.show()
Always start bar chart y-axis at zero
A y-axis that starts at 20,000 instead of 0 makes a 5% difference look like a 3x difference. Bar chart heights encode magnitude — that encoding breaks when the baseline is not zero. For line charts, starting at zero is less critical (the slope matters, not absolute height).
Scatter Plot — Relationships Between Variables¶
Use when: exploring whether two numeric variables are related.
import numpy as np
np.random.seed(42)
n = 300
unit_prices = np.random.exponential(50, n)
totals = unit_prices * np.random.uniform(1, 5, n) + np.random.normal(0, 15, n)
fig, ax = plt.subplots(figsize=(9, 6))
scatter = ax.scatter(
unit_prices, totals,
alpha=0.5, c=unit_prices, cmap="viridis", s=45, linewidths=0
)
cbar = plt.colorbar(scatter, ax=ax)
cbar.set_label("Unit Price (£)", fontsize=10)
# Trend line
z = np.polyfit(unit_prices, totals, 1)
p = np.poly1d(z)
x_line = np.linspace(unit_prices.min(), unit_prices.max(), 200)
ax.plot(x_line, p(x_line), "r--", alpha=0.7, linewidth=1.5, label="Trend")
ax.set_xlabel("Unit Price (£)", fontsize=11)
ax.set_ylabel("Order Total (£)", fontsize=11)
ax.set_title("Higher Unit Price Correlates with Larger Order Total", fontweight="bold")
ax.legend()
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()
# Correlation coefficient
r = np.corrcoef(unit_prices, totals)[0, 1]
print(f"Pearson r = {r:.3f}") # e.g., 0.874
Histogram — Distribution of One Variable¶
Use when: understanding how values are spread across a range.
data = np.random.lognormal(5, 0.8, 500) # right-skewed, like revenue
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
# Raw distribution
axes[0].hist(data, bins=35, color="#0D9488", edgecolor="white", alpha=0.85)
axes[0].axvline(np.mean(data), color="#EF4444", linestyle="--",
linewidth=2, label=f"Mean £{np.mean(data):.0f}")
axes[0].axvline(np.median(data), color="#F59E0B", linestyle="--",
linewidth=2, label=f"Median £{np.median(data):.0f}")
axes[0].set_title("Order Value Distribution — Right-Skewed", fontweight="bold")
axes[0].set_xlabel("Order Value (£)")
axes[0].set_ylabel("Frequency")
axes[0].legend()
axes[0].spines["top"].set_visible(False)
axes[0].spines["right"].set_visible(False)
# Log-transformed — normalizes the skew
axes[1].hist(np.log1p(data), bins=35, color="#F59E0B", edgecolor="white", alpha=0.85)
axes[1].set_title("Order Value Distribution — Log Transformed", fontweight="bold")
axes[1].set_xlabel("log(1 + Order Value)")
axes[1].set_ylabel("Frequency")
axes[1].spines["top"].set_visible(False)
axes[1].spines["right"].set_visible(False)
plt.tight_layout()
plt.show()
# When mean >> median, data is right-skewed — report median, not mean
print(f"Mean: £{np.mean(data):.0f} | Median: £{np.median(data):.0f}")
How many bins?
Too few bins hide the distribution shape. Too many create noise. A good starting rule: bins = int(np.sqrt(len(data))). For sample sizes up to 1,000, 25–40 bins usually work well. Always check how the shape changes with different bin counts.
Multi-Panel Figure — Telling a Complete Story¶
Use when: presenting multiple related views of the same data as an analytics summary.
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle("Sales Performance Dashboard — Q1 2024",
fontsize=16, fontweight="bold", y=1.01)
months = ["Jan", "Feb", "Mar"]
revenue = [48250, 52100, 55800]
orders = [185, 210, 234]
avg_order = [r / o for r, o in zip(revenue, orders)]
categories = ["Electronics", "Books", "Furniture", "Kitchen", "Sports"]
cat_rev = [22000, 8500, 12000, 6200, 4500]
# Top-left: monthly revenue bar chart
ax_tl = axes[0, 0]
ax_tl.bar(months, revenue, color="#0D9488", alpha=0.85, edgecolor="white")
ax_tl.set_title("Monthly Revenue", fontweight="bold")
ax_tl.set_ylabel("Revenue (£)")
ax_tl.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"£{x/1000:.0f}k"))
for i, (m, r) in enumerate(zip(months, revenue)):
ax_tl.text(i, r + 400, f"£{r/1000:.0f}k", ha="center", fontsize=9)
ax_tl.spines["top"].set_visible(False)
ax_tl.spines["right"].set_visible(False)
# Top-right: order count line chart
ax_tr = axes[0, 1]
ax_tr.plot(months, orders, marker="o", color="#F59E0B", linewidth=2.5)
ax_tr.fill_between(months, orders, alpha=0.15, color="#F59E0B")
ax_tr.set_title("Orders per Month", fontweight="bold")
ax_tr.set_ylabel("Order Count")
ax_tr.spines["top"].set_visible(False)
ax_tr.spines["right"].set_visible(False)
# Bottom-left: avg order value
ax_bl = axes[1, 0]
ax_bl.bar(months, avg_order, color="#3B82F6", alpha=0.85, edgecolor="white")
ax_bl.set_title("Average Order Value", fontweight="bold")
ax_bl.set_ylabel("Avg £ per Order")
ax_bl.spines["top"].set_visible(False)
ax_bl.spines["right"].set_visible(False)
# Bottom-right: category breakdown (horizontal bar)
ax_br = axes[1, 1]
h_colors = ["#0D9488"] + ["#CBD5E1"] * (len(categories) - 1)
ax_br.barh(categories[::-1], cat_rev[::-1], color=h_colors[::-1], edgecolor="white")
ax_br.set_title("Revenue by Category", fontweight="bold")
ax_br.set_xlabel("Revenue (£)")
ax_br.spines["top"].set_visible(False)
ax_br.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()
Saving Charts¶
# Save before plt.show() — show() clears the figure
fig.savefig("chart.png", dpi=150, bbox_inches="tight") # presentations, email
fig.savefig("chart.svg", bbox_inches="tight") # web (scales without pixelation)
fig.savefig("chart.pdf", bbox_inches="tight") # PDF reports
# For white-background exports (Jupyter often renders transparent backgrounds)
fig.savefig("chart.png", dpi=150, bbox_inches="tight",
facecolor="white", edgecolor="none")
# High-resolution for print
fig.savefig("chart_print.png", dpi=300, bbox_inches="tight")
For senior analysts
When generating charts programmatically (one per region, one per product), always call `plt.close(fig)` after saving. If you forget, matplotlib keeps every figure in memory. At 50 charts, this causes memory issues that can crash the kernel.
Annotations — Drawing Attention to Insights¶
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
revenue = [48250, 38500, 51200, 49800, 55100, 48250]
fig, ax = plt.subplots(figsize=(11, 5))
ax.plot(months, revenue, marker="o", color="#0D9488", linewidth=2.5)
# Annotate the dip
min_idx = revenue.index(min(revenue))
ax.annotate(
f"Post-holiday dip\n£{revenue[min_idx]:,}",
xy=(min_idx, revenue[min_idx]),
xytext=(min_idx + 0.8, revenue[min_idx] - 4000),
arrowprops=dict(arrowstyle="->", color="#64748B"),
fontsize=9, color="#64748B"
)
# Annotate the peak
max_idx = revenue.index(max(revenue))
ax.annotate(
f"Record month: £{revenue[max_idx]:,}",
xy=(max_idx, revenue[max_idx]),
xytext=(max_idx - 1.5, revenue[max_idx] + 1800),
arrowprops=dict(arrowstyle="->", color="#0D9488"),
fontsize=9, color="#0D9488", fontweight="bold"
)
ax.set_title("May Was the Record Month — Feb Dip Was Post-Holiday", fontweight="bold")
ax.set_xlabel("Month")
ax.set_ylabel("Revenue (£)")
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"£{x:,.0f}"))
ax.set_ylim(30000, 62000)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()
Practice Exercises¶
Warm-up
- Create a line chart showing monthly revenue for 6 months with a dashed target line. Include a legend.
- Create a bar chart of revenue by category with value labels on each bar. Highlight the top category in teal, all others in grey.
- Create a 2-panel figure: bar chart on the left, line chart on the right.
Main
- Build a scatter plot of
unit_pricevstotalfrom your orders data. Add a trend line and color points by category. - Create a histogram of order totals with mean and median vertical lines. Label both with their values.
- Build a 2×2 multi-panel figure summarising: revenue by month (bar), order count by month (line), category breakdown (horizontal bar), and order status distribution (histogram or bar).
Stretch
- Add an annotation to your line chart: mark the highest-revenue month with an arrow and a text label that states the value.
- Build a function
make_clean_ax(ax, title, xlabel, ylabel)that applies consistent formatting — remove top/right spines, set labels, add grid. Use it across all your charts.
Interview Questions¶
[Beginner] What is the difference between plt.figure() and fig, ax = plt.subplots()?
Show answer
Both create a figure, but fig, ax = plt.subplots() returns explicit handles to both the Figure and the Axes object, giving you precise control over each element. plt.figure() works on an implicit "current axes" concept that breaks when you have multiple subplots or generate charts in a loop. The two-handle pattern fig, ax = plt.subplots() is the industry-standard approach for production code.
[Mid-level] How do you create a figure with two charts side by side in matplotlib?
Show answer
For more complex layouts, usegridspec:
[Senior] You need to programmatically generate 50 charts — one per product — and save them to a folder. How would you structure this?
Show answer
from pathlib import Path
import matplotlib.pyplot as plt
output_dir = Path("product_charts")
output_dir.mkdir(exist_ok=True)
for product in df["product"].unique():
subset = df[df["product"] == product]
if len(subset) < 5:
continue # skip products with too little data
fig, ax = plt.subplots(figsize=(10, 5))
monthly = subset.groupby(subset["order_date"].dt.to_period("M"))["total"].sum()
ax.bar(monthly.index.astype(str), monthly.values / 1000, color="#0D9488")
ax.set_title(f"Monthly Revenue — {product}", fontweight="bold")
ax.set_ylabel("Revenue (£k)")
ax.tick_params(axis="x", rotation=45)
plt.tight_layout()
safe_name = product.replace("/", "-").replace(" ", "_")
fig.savefig(output_dir / f"{safe_name}.png", dpi=120, bbox_inches="tight")
plt.close(fig) # CRITICAL: prevents memory leak
print(f"Saved {len(list(output_dir.glob('*.png')))} charts")
plt.close(fig) prevents memory leaks; use Path for cross-platform file paths; sanitize filenames; skip sparse subsets; validate output count.
Previous: Agenda | Next: Seaborn Basics