Skip to content

Matplotlib Essentials

The foundation of Python plotting. Matplotlib gives you total control over every line, tick, and pixel — the go-to for publication figures and the engine under seaborn and pandas' .plot().

Table of Contents

Installation / Setup

pip install matplotlib      # or: uv add matplotlib
import matplotlib.pyplot as plt

# In Jupyter notebooks:
%matplotlib inline

For headless scripts (servers, CI), pick the Agg backend before importing pyplot:

import matplotlib
matplotlib.use("Agg")          # render to files only, no window
import matplotlib.pyplot as plt

Core Syntax

The mental model: Figure → Axes

fig, ax = plt.subplots(figsize=(8, 5))   # one axes
fig, axes = plt.subplots(2, 2, figsize=(10, 8))   # 2x2 grid -> axes is (2,2) array

ax.plot(x, y)                # draw on a specific axes (recommended)
plt.plot(x, y)               # pyplot stateful shorthand (fine for quick EDA)

Two styles coexist: the object-oriented API (fig, ax) is preferred for control and multi-panel figures; plt.* functions act on "the current axes".

Basic chart types

Chart Function Typical data
Line ax.plot(x, y, label=...) Time series, trends
Bar ax.bar(x, heights), ax.barh(x, heights) Category totals
Histogram ax.hist(values, bins=30, density=True) Distribution of one variable
Scatter ax.scatter(x, y, s=, c=, alpha=) Two numeric variables
Box ax.boxplot(data, labels=...) Distribution by group
Violin ax.violinplot(data) Distribution shape by group
Heatmap ax.imshow(matrix, cmap="viridis", aspect="auto") 2-D matrix (correlations)
Text/annotations ax.text(x, y, s), ax.annotate(...) Labels and callouts
x = np.arange(5)
y = np.array([3, 7, 2, 8, 5])

fig, ax = plt.subplots()
ax.plot(x, y, marker="o", linestyle="--", linewidth=2, color="#1f77b4", label="score")
ax.bar(x + 0.2, y, width=0.4, alpha=0.6, label="bars")
ax.set_xlabel("x"); ax.set_ylabel("y"); ax.set_title("Example")
ax.legend()

Subplots

fig, axes = plt.subplots(2, 2, figsize=(10, 7), sharex=True)
axes[0, 0].hist(df["a"], bins=30)
axes[0, 1].scatter(df["a"], df["b"])
axes[1, 0].boxplot([df["a"], df["b"]], labels=["a", "b"])
axes[1, 1].plot(x, y)

fig.suptitle("Big title", fontsize=16)
fig.tight_layout()                     # fix overlapping labels
# or pass constrained_layout=True to plt.subplots()

axes from plt.subplots(nrows, ncols) is a NumPy array — flatten before looping: for ax_i in axes.flat:.

Axes, ticks, legends

ax.set_xlabel("time (s)"); ax.set_ylabel("score")
ax.set_title("Title"); ax.set_xlim(0, 10); ax.set_ylim(0, 100)
ax.set_xticks([0, 2, 4]); ax.set_xticklabels(["a", "b", "c"], rotation=45)
ax.tick_params(axis="x", labelsize=9)
ax.grid(True, linestyle=":", alpha=0.6)
ax.legend(loc="best", title="groups")          # loc: "upper left", "lower right", ...
ax.axhline(y=df["score"].mean(), color="r", ls="--")   # reference line
ax.axvline(x=threshold, color="k", ls=":")
ax.twinx()                                       # second y-axis (right side)

Annotations

ax.annotate("peak", xy=(x_best, y_best), xytext=(x_best + 0.5, y_best - 10),
            arrowprops=dict(arrowstyle="->", color="red"))
ax.text(0.02, 0.98, "n=1000", transform=ax.transAxes, va="top", fontsize=9)

transform=ax.transAxes positions text relative to the axes (0–1) so it stays put on resize.

Styles & colors

plt.style.available                     # list installed styles
plt.style.use("seaborn-v0_8-whitegrid") # popular: ggplot, fivethirtyeight, default
with plt.style.context("ggplot"):       # style scoped to a block
    fig, ax = plt.subplots(); ax.plot(x, y)

ax.plot(x, y, color="#2ca02c")          # hex, named, or RGB(A)
ax.scatter(x, y, c=values, cmap="viridis")   # colormaps: viridis, plasma, coolwarm, RdYlBu
fig.colorbar(im, ax=ax, label="density")

Sequential data → sequential maps (viridis, cividis); diverging data → coolwarm/RdBu; avoid the legacy jet.

Exporting figures

fig.savefig("figures/result.png", dpi=300, bbox_inches="tight")
fig.savefig("paper/fig1.pdf")                 # vector — best for papers
fig.savefig("fig1.svg", transparent=True)
# common options: dpi, bbox_inches="tight", facecolor, transparent, format

Always savefig from the fig object (not plt) after drawing but ideally before plt.show().

Common Operations

# Color points by a third variable
sc = ax.scatter(df["x"], df["y"], c=df["z"], cmap="viridis", s=20, alpha=0.7)
fig.colorbar(sc, label="z")

# Histogram + KDE-ish overlay
ax.hist(values, bins=30, density=True, alpha=0.6, label="data")
# smooth density: use seaborn.histplot(kde=True) instead

# Grouped box/violin from arrays
groups = [df[df["g"] == g]["v"].values for g in ["a", "b", "c"]]
ax.boxplot(groups, labels=["a", "b", "c"], patch_artist=True)

# Two lines, two scales
ax.plot(t, temp, color="tab:red")
ax2 = ax.twinx()
ax2.plot(t, humidity, color="tab:blue")

Practical Examples

1. Annotated bar chart of model scores

import matplotlib.pyplot as plt
import numpy as np

models = ["baseline", "logistic", "xgboost"]
scores = np.array([0.72, 0.84, 0.91])

fig, ax = plt.subplots(figsize=(7, 4))
bars = ax.bar(models, scores, color=["#7f8c8d", "#3498db", "#2ecc71"])
ax.set_ylim(0, 1); ax.set_ylabel("accuracy"); ax.set_title("Model comparison")
ax.axhline(0.8, color="k", ls=":", lw=1)

for bar, s in zip(bars, scores):           # annotate each bar
    ax.text(bar.get_x() + bar.get_width() / 2, s + 0.01, f"{s:.2f}",
            ha="center", fontsize=10)
fig.savefig("model_comparison.png", dpi=300, bbox_inches="tight")

2. EDA dashboard with subplots

fig, axes = plt.subplots(2, 2, figsize=(12, 8))
axes[0, 0].hist(df["age"], bins=30, edgecolor="white"); axes[0, 0].set_title("Age")
axes[0, 1].scatter(df["age"], df["income"], s=8, alpha=0.5)
axes[1, 0].boxplot([df[df["churn"] == 1]["age"], df[df["churn"] == 0]["age"]],
                   labels=["churn", "no churn"])
axes[1, 1].bar(*np.unique(df["plan"], return_counts=True))
fig.tight_layout()

3. Trend lines with shaded uncertainty

rng = np.random.default_rng(0)
weeks = np.arange(12)
mean = np.linspace(60, 90, 12) + rng.normal(0, 2, 12)
std = np.full(12, 5.0)

fig, ax = plt.subplots()
ax.plot(weeks, mean, marker="o", label="weekly accuracy")
ax.fill_between(weeks, mean - std, mean + std, alpha=0.2, label="±1 std")
ax.set_xlabel("week"); ax.set_ylabel("accuracy (%)")
ax.legend(); ax.grid(ls=":", alpha=0.5)

Common Patterns

# One plot per numeric column
numeric = df.select_dtypes("number").columns
fig, axes = plt.subplots(2, (len(numeric) + 1) // 2, figsize=(12, 6))
for ax_i, col in zip(axes.flat, numeric):
    ax_i.hist(df[col], bins=40); ax_i.set_title(col)
fig.tight_layout()

# Share axis on purpose
fig, axes = plt.subplots(1, 3, sharey=True)   # comparable heights

# Loop drawing + legend once
for grp, gdf in df.groupby("region"):
    ax.plot(gdf["date"], gdf["sales"], label=grp)
ax.legend()

# Consistent style for a whole notebook
plt.rcParams.update({"font.size": 11, "axes.grid": True, "figure.dpi": 110})

Best Practices

  • Use the object-oriented API (fig, ax) — needed for subplots and reusable code.
  • Label every axis with units, title every figure; add legends when >1 series.
  • Export at dpi=300 (print) or vector PDF/SVG for papers; bbox_inches="tight" to avoid clipped labels.
  • Keep defaults sensible: start y-axis at 0 for bar charts, not necessarily for line/scatter.
  • Prefer colormaps that are perceptually uniform (viridis, cividis) and colorblind-safe.
  • Small multiples beat clutter — split many series into subplots with shared axes.
  • Build figures from tidy data (one row per observation) with pandas/seaborn, then polish with Matplotlib.

Common Mistakes

Mistake Consequence Correct approach
axes[0,0] on a 1-D subplot IndexError Check shape; use .flat or index [0] for 1-D
Calling plt.title() with multiple axes Title lands on the last axes only ax.set_title(...) per axes
savefig after show() Saves a blank figure Save before show(), or fig.savefig after drawing
Not calling fig.tight_layout() Overlapping labels/titles tight_layout() or constrained_layout=True
Plotting in a loop in Jupyter 100 figures spam the notebook Draw on one fig, axes grid
15 series, default color cycle Unreadable rainbow Small multiples + shared legend, or categorical palettes
Strings passed where numbers needed TypeError / alphabetical x-order Parse dtypes first

Troubleshooting

Symptom Cause Fix
TclError: no display name (headless) No GUI on server matplotlib.use("Agg") before import
Blank plot in Jupyter %matplotlib inline missing / wrong backend Add the magic or restart kernel
Chinese/emoji shown as boxes Missing glyph in default font Set font.family to a font with the glyphs
Legend overlaps data Default placement bbox_to_anchor=(1.02, 1) outside, or loc="upper left"
Dates on x-axis as raw numbers Datetime not parsed pd.to_datetime; use ax.xaxis.set_major_formatter if needed
MatplotlibDeprecationWarning about styles Old seaborn style name Use current names: seaborn-v0_8-whitegrid

Official Documentation

Last Updated

2026-09-06 — Initial version.