Getting Started
Installation & First Plot
Matplotlib is the foundation of Python plotting. pyplot is the state-based interface (like MATLAB). Always call plt.show() to render the figure in a script; in Jupyter notebooks the figure renders automatically when a cell ends.
# install
pip install matplotlib
import matplotlib.pyplot as plt
import numpy as np
# a simple line plot
x = np.linspace(0, 10, 100)
y = np.sin(x)
plt.plot(x, y)
plt.title("Sine Wave")
plt.xlabel("x")
plt.ylabel("sin(x)")
plt.show()pyplot State Interface
The pyplot interface mirrors MATLAB: each call operates on the 'current' figure/axes tracked internally. It is concise for quick plots but can get confusing with multiple subplots. Use plt.gcf()/plt.gca() to access the current figure/axes objects.
import matplotlib.pyplot as plt
# pyplot tracks the "current" figure and axes
plt.figure() # create a new figure
plt.plot([1, 2, 3], [4, 5, 2])
plt.title("State-based") # applies to the current axes
plt.xlim(0, 4)
plt.ylim(0, 6)
plt.show()
# plt.gcf() -> current figure, plt.gca() -> current axes
print(plt.gca())Figure & Axes (OO Interface)
The Object-Oriented (OO) interface is the recommended approach for anything beyond a single plot. You create a Figure (the whole canvas) and one or more Axes (individual plots) and call methods on them. It scales cleanly to subplots, twin axes and complex layouts.
import matplotlib.pyplot as plt
import numpy as np
# object-oriented interface: explicit Figure and Axes objects
fig, ax = plt.subplots() # fig is the canvas, ax is a single Axes
x = np.linspace(0, 5, 50)
ax.plot(x, x**2, label="x^2")
ax.plot(x, x**3, label="x^3")
ax.set_title("OO Interface")
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.legend()
plt.show()Common Imports & Conventions
plt is the near-universal alias for pyplot and np for NumPy — almost every tutorial and library expects these. rcParams is the global configuration dictionary: set it once at the top of a script to fix DPI, font size, figure size and many other defaults.
# the universal alias
import matplotlib.pyplot as plt
import numpy as np
# for the OO interface you often see
from matplotlib.figure import Figure
from matplotlib.axes import Axes
# set a global style once
plt.rcParams["figure.dpi"] = 100
plt.rcParams["font.size"] = 11
# inline backend in Jupyter (not needed in scripts)
# %matplotlib inlineBackends & plt.show()
A backend renders the figure: interactive backends (TkAgg, Qt5Agg) open a window, non-interactive ones (Agg) only write files. Call matplotlib.use() before importing pyplot. plt.show() blocks in scripts until windows close; in Jupyter, %matplotlib inline embeds figures in the notebook.
import matplotlib
# list available backends
print(matplotlib.rcsetup.all_backends)
# force a backend (do this BEFORE importing pyplot)
# matplotlib.use("Agg") # non-interactive, for saving files
# matplotlib.use("TkAgg") # interactive Tk window
import matplotlib.pyplot as plt
plt.plot([1, 2, 3])
# plt.show() blocks until the window is closed
plt.savefig("out.png") # saving works without any GUI backendLine Plots (plot)
Basic Line Plot
plt.plot(x, y) draws a line connecting the (x, y) points in order. If you pass a single array it is used as y and x defaults to 0..N-1. plt.grid(True) toggles gridlines. Points are connected in array order, so for a smooth curve sort x first.
import matplotlib.pyplot as plt
import numpy as np
x = np.arange(0, 10, 0.5)
y = x * 2
plt.plot(x, y)
plt.title("y = 2x")
plt.xlabel("x")
plt.ylabel("y")
plt.grid(True)
plt.show()Multiple Lines
Each plt.plot() call adds a new line to the current axes and auto-advances the color cycle. Pass a label= to each so plt.legend() can identify them. Matplotlib handles axis limits automatically, expanding to fit the largest range.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 2 * np.pi, 100)
plt.plot(x, np.sin(x), label="sin")
plt.plot(x, np.cos(x), label="cos")
plt.plot(x, np.sin(x) + np.cos(x), label="sin+cos")
plt.legend()
plt.title("Trig Functions")
plt.show()Line Styles & Markers
The format string 'color+marker+linestyle' (e.g. 'ro--') is compact but cryptic. Prefer explicit color=, marker=, linestyle= keyword arguments for readable code. Common linestyles: '-' '--' ':' '-.'. Common markers: 'o' 's' '^' 'D' 'v' '+' 'x'.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 5, 20)
# format string: [color][marker][linestyle]
plt.plot(x, x, "r-") # red solid line
plt.plot(x, x*2, "go--") # green circles, dashed
plt.plot(x, x*3, "bs:") # blue squares, dotted
plt.plot(x, x*4, "k^-.") # black triangles, dash-dot
# explicit kwargs (clearer, recommended)
plt.plot(x, x*5, color="purple", marker="D",
linestyle="-.", linewidth=2, markersize=6)Colors
Matplotlib accepts many color formats: names ('red'), hex strings ('#FF8800'), RGB(A) tuples, Tableau names ('tab:blue'), cycle references ('C0'..'C9') and grayscale strings ('0.5'). 'C0' picks the current style's first color, keeping plots consistent with the color cycle.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 1, 30)
plt.plot(x, x, color="red") # named color
plt.plot(x, x+0.2, color="#FF8800") # hex RGB
plt.plot(x, x+0.4, color=(0.1, 0.8, 0.2))# RGB tuple 0-1
plt.plot(x, x+0.6, color="C0") # cycle color 0
plt.plot(x, x+0.8, color="tab:purple") # Tableau palette
# gray shorthand: a string of float in [0,1]
plt.plot(x, x+1.0, color="0.5") # mid gray
plt.show()Line Width, Alpha & zorder
linewidth controls stroke thickness (default 1.5). alpha sets transparency and is essential when curves overlap so you can see what is beneath. zorder controls stacking — higher values are drawn later (on top). Default zorder is 2 for lines, 1 for patches.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 100)
# alpha = transparency (0 invisible, 1 opaque)
# zorder = draw order; higher draws on top
plt.fill_between(x, np.sin(x), alpha=0.3, zorder=1)
plt.plot(x, np.sin(x), linewidth=2, zorder=2)
plt.plot(x, np.cos(x), linewidth=4, alpha=0.7, zorder=3)
plt.show()Step & Stairs Plots
plt.step() draws step functions useful for digital signals and discrete data; where controls where the step rises ('pre', 'post', 'mid'). plt.stairs() (3.4+) is the modern equivalent that takes y values and separate bin edges, and integrates well with hist-like data.
import matplotlib.pyplot as plt
import numpy as np
x = np.arange(5)
y = [1, 3, 2, 4, 3]
# step: like plot but with horizontal/vertical steps
plt.step(x, y, where="post", label="post")
plt.step(x, y + 1, where="mid", label="mid")
plt.step(x, y + 2, where="pre", label="pre")
plt.legend(title="where=")
# stairs (matplotlib 3.4+): step from explicit bin edges
plt.stairs([1, 2, 1], baseline=0)
plt.show()Scatter Plots
Basic Scatter
plt.scatter(x, y) plots points without connecting lines. It is slower than plt.plot(x, y, 'o') when you have tens of thousands of points because scatter lets each point have its own size and color. Use plt.plot with a marker for max speed with uniform styling.
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(0)
x = rng.random(50)
y = rng.random(50)
plt.scatter(x, y)
plt.title("Basic Scatter")
plt.xlabel("x")
plt.ylabel("y")
plt.axis("equal") # equal aspect ratio
plt.show()Marker Size & Color
The s argument is marker AREA in points squared (not radius), so a 4x larger s is only 2x the radius. The c (color) argument with cmap maps a numeric array onto a colormap. Use alpha < 1 when points overlap so density is visible.
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(1)
n = 50
x = rng.random(n)
y = rng.random(n)
sizes = rng.random(n) * 500 # area in points^2
colors = rng.random(n) # mapped to colormap
plt.scatter(x, y, s=sizes, c=colors, cmap="viridis", alpha=0.6)
plt.colorbar(label="value")
plt.title("Bubble Chart")
plt.show()Colormap Scatter
Passing a 1-D array to c maps each point to a colormap; plt.colorbar(sc) adds a colorbar (you must pass the scatter's return value so the colorbar knows the mapping). vmin/vmax fix the colormap range; without them matplotlib autoscales to the data min/max.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 200)
y = np.sin(x)
c = x # color by x value
sc = plt.scatter(x, y, c=c, cmap="plasma", vmin=0, vmax=10)
cb = plt.colorbar(sc)
cb.set_label("x position")
plt.title("Colored by x")
plt.show()Transparency & Overplotting
With many overlapping points a solid scatter hides density. Drop alpha to ~0.05 and shrink the marker to reveal structure. For very large data (100k+ points), plt.hexbin or plt.hist2d aggregate into bins and are both faster and clearer than raw scatter.
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(2)
n = 5000
x = rng.normal(0, 1, n)
y = rng.normal(0, 1, n)
# dense scatter: low alpha reveals density
plt.scatter(x, y, s=5, alpha=0.05, c="navy")
plt.title("Density via alpha")
# alternative: hexbin for very large datasets
plt.figure()
plt.hexbin(x, y, gridsize=40, cmap="Blues")
plt.colorbar(label="count")
plt.show()Categorical Scatter
Matplotlib has no built-in strip plot, so scatter with manual jitter on the x-axis is the standard DIY approach. For publication-quality categorical plots (box, violin, swarm) prefer Seaborn, which is built on Matplotlib and integrates with plt styling.
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(3)
groups = ["A", "B", "C"]
data = [rng.normal(0, 1, 30),
rng.normal(2, 1, 30),
rng.normal(1, 1.5, 30)]
for i, d in enumerate(data):
# add jitter on x so points do not stack on one line
jitter = rng.uniform(-0.1, 0.1, len(d))
plt.scatter(np.full(len(d), i) + jitter, d, label=groups[i])
plt.xticks(range(len(groups)), groups)
plt.legend()
plt.show()Bar Charts
Vertical Bar Chart
plt.bar(x, height) draws vertical bars; x may be strings (categorical). The returned BarContainer lets you annotate or recolor individual bars. The loop with plt.text() labels each bar — adjust ha/va ('center'/'bottom') and the y offset so labels sit just above the bar.
import matplotlib.pyplot as plt
categories = ["Apple", "Banana", "Cherry", "Date"]
values = [25, 40, 30, 15]
bars = plt.bar(categories, values, color="tab:blue", edgecolor="black")
# annotate each bar with its value
for bar, v in zip(bars, values):
plt.text(bar.get_x() + bar.get_width()/2, v + 1,
str(v), ha="center", va="bottom")
plt.title("Fruit Sales")
plt.ylabel("units sold")
plt.ylim(0, 50)
plt.show()Horizontal Bar Chart
plt.barh(y, width) draws horizontal bars — ideal for long category labels that would overlap on a vertical chart. Call ax.invert_yaxis() so the first item appears at the top, matching how people read lists. Width defaults to 0.8.
import matplotlib.pyplot as plt
items = ["Reading", "Coding", "Sleep", "Exercise"]
hours = [2, 6, 8, 1]
plt.barh(items, hours, color="tab:green")
plt.xlabel("hours per day")
plt.title("Daily Activities")
# invert so the first item appears at the top
plt.gca().invert_yaxis()
plt.show()Grouped Bar Chart
Grouped bars place two (or more) series side by side by offsetting the x positions by half the bar width. The pattern x +/- w/2 with width w places them flush. Use np.arange for numeric x positions so the math works, then relabel with plt.xticks.
import matplotlib.pyplot as plt
import numpy as np
labels = ["Q1", "Q2", "Q3", "Q4"]
a = [20, 35, 30, 35]
b = [25, 32, 34, 20]
x = np.arange(len(labels))
w = 0.35
plt.bar(x - w/2, a, w, label="2023")
plt.bar(x + w/2, b, w, label="2024")
plt.xticks(x, labels)
plt.ylabel("revenue")
plt.legend()
plt.show()Stacked Bar Chart
Stacked bars use the bottom= argument: each series starts where the previous one ended. For more than two series, accumulate: bottom = [m+w for m, w in zip(men, women)]. Stacked bars show totals and composition, but make the upper series harder to compare across categories.
import matplotlib.pyplot as plt
import numpy as np
labels = ["A", "B", "C"]
men = [20, 35, 30]
women = [25, 32, 34]
plt.bar(labels, men, label="Men")
plt.bar(labels, women, bottom=men, label="Women")
plt.legend()
plt.title("Stacked Bars")
plt.show()Bar with Error Bars
The yerr argument adds vertical error bars; capsize sets the length of the end caps. Pass error_kw to style the bars. For asymmetric errors pass yerr=[lower_errors, upper_errors]. This works on barh (xerr) and on plt.errorbar too.
import matplotlib.pyplot as plt
import numpy as np
labels = ["A", "B", "C"]
means = [20, 35, 30]
errors = [2, 4, 3]
plt.bar(labels, means, yerr=errors, capsize=6,
color="tab:orange", edgecolor="black",
error_kw={"ecolor": "black", "linewidth": 1.5})
plt.title("Bar with Error Bars")
plt.ylabel("mean value")
plt.show()Histograms
Basic Histogram
plt.hist(x) bins the data and draws bars. bins can be an integer (number of bins) or a sequence of explicit edges. By default it returns counts, so the y-axis is frequency. The return value is (counts, bin_edges, patches) which you can use for further customization.
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(0)
data = rng.normal(0, 1, 1000)
plt.hist(data, bins=30)
plt.title("Histogram")
plt.xlabel("value")
plt.ylabel("frequency")
plt.show()Choosing Bins
Bin choice drastically changes the look of a histogram. 'auto' uses the maximum of Sturges and Freedman-Diaconis rules — a sensible default. Pass an explicit edge array for full control and comparable bins across datasets. Too few bins hides structure; too many add noise.
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(1)
data = rng.normal(0, 1, 1000)
# auto bin selection (Freedman-Diaconis, Sturges, etc.)
plt.hist(data, bins="auto", alpha=0.5, label="auto")
plt.hist(data, bins=10, alpha=0.5, label="10 bins")
plt.hist(data, bins=np.arange(-4, 4, 0.25),
alpha=0.5, label="0.25 width")
plt.legend()
plt.show()Multiple Histograms
Overlapping histograms need alpha < 1 to see both distributions; use distinct colors. For more than two groups, stacked=True avoids clutter but makes comparison harder. Consider kdeplot (Seaborn) for smooth density curves that overlay more cleanly than histograms.
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(2)
a = rng.normal(0, 1, 1000)
b = rng.normal(1, 1.2, 1000)
plt.hist(a, bins=30, alpha=0.6, label="A", color="tab:blue")
plt.hist(b, bins=30, alpha=0.6, label="B", color="tab:orange")
plt.legend()
plt.show()
# stacked alternative
plt.figure()
plt.hist([a, b], bins=30, stacked=True, label=["A", "B"])
plt.legend()
plt.show()Density / Normalized
density=True divides counts by (total * bin_width) so the bar areas sum to 1, producing a probability density. This makes histograms comparable across different sample sizes and lets you overlay a theoretical PDF. Use weights= to weight individual samples.
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(3)
data = rng.normal(0, 1, 1000)
# density=True normalizes the area to 1 (PDF)
plt.hist(data, bins=30, density=True, alpha=0.6)
# overlay the theoretical PDF
from scipy.stats import norm
xs = np.linspace(-4, 4, 200)
plt.plot(xs, norm.pdf(xs), "r-", lw=2)
plt.title("Normalized Histogram + PDF")
plt.show()2D Histogram
plt.hist2d bins two variables into a 2-D grid and colors each cell by count — the discrete cousin of a scatter for dense data. For smoother output use plt.hexbin (hexagonal bins). Both accept a cmap and return values you can pass to colorbar.
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(4)
x = rng.normal(0, 1, 10000)
y = x + rng.normal(0, 1, 10000)
plt.hist2d(x, y, bins=40, cmap="Blues")
plt.colorbar(label="count")
plt.title("2D Histogram")
plt.xlabel("x")
plt.ylabel("y")
plt.show()Pie Charts
Basic Pie Chart
plt.pie(x, labels) draws a pie from the values in x. autopct formats the percentage labels (a printf-style string or function). startangle rotates the start of the first wedge. Always plt.axis('equal') so the pie is a true circle rather than an ellipse.
import matplotlib.pyplot as plt
labels = ["Rent", "Food", "Transport", "Fun"]
sizes = [1200, 600, 300, 400]
plt.pie(sizes, labels=labels, autopct="%1.1f%%", startangle=90)
plt.title("Monthly Budget")
plt.axis("equal") # circle, not ellipse
plt.show()Explode & Shadows
explode is a tuple of the same length as sizes; each value offsets that wedge outward from the center (0 = no offset). shadow=True adds a drop shadow for depth. Use explode sparingly to highlight one slice — over-exploding every wedge looks cluttered.
import matplotlib.pyplot as plt
labels = ["A", "B", "C", "D"]
sizes = [30, 25, 25, 20]
explode = (0, 0.1, 0, 0) # pull the 2nd wedge out
plt.pie(sizes, labels=labels, explode=explode,
shadow=True, autopct="%1.0f%%",
startangle=140)
plt.title("Exploded Pie")
plt.show()Donut Chart
A donut is just a pie with a hole: set wedgeprops={'width': 0.4} to draw rings instead of full wedges. The center hole can hold a summary label via plt.text(0, 0, ...). Donuts are generally easier to read than pies because the ring length encodes value better than wedge angle.
import matplotlib.pyplot as plt
labels = ["A", "B", "C"]
sizes = [40, 35, 25]
wedges, texts = plt.pie(sizes, labels=labels,
wedgeprops=dict(width=0.4, edgecolor="w"))
plt.title("Donut Chart")
# add center text
plt.text(0, 0, "Total\n100", ha="center", va="center", fontsize=14)
plt.show()Percentage & Start Angle
autopct can be a function that receives the percentage and returns a string, letting you show both percent and absolute count. startangle + counterclock=False control wedge direction (start at top, go clockwise — the conventional reading order). pctdistance positions the percent labels.
import matplotlib.pyplot as plt
labels = ["North", "South", "East", "West"]
sizes = [32, 24, 28, 16]
colors = ["#4C72B0", "#DD8452", "#55A868", "#C44E52"]
plt.pie(sizes, labels=labels, colors=colors,
autopct=lambda p: f"{p:.1f}%\n({p*sum(sizes)/100:.0f})",
startangle=90, counterclock=False,
pctdistance=0.75)
plt.show()Nested Pie (Concentric)
Two plt.pie calls with different radius and width values create concentric rings, a simple 'sunburst'. The outer ring typically shows top-level categories and the inner ring their breakdowns. Keep the inner radius smaller and add edgecolor='w' to separate wedges visually.
import matplotlib.pyplot as plt
# outer ring: regions, inner ring: sub-categories
outer = [40, 30, 30]
inner = [20, 20, 15, 15, 25, 5]
outer_labels = ["A", "B", "C"]
inner_labels = ["A1", "A2", "B1", "B2", "C1", "C2"]
plt.pie(outer, labels=outer_labels, radius=1.0,
wedgeprops=dict(width=0.3, edgecolor="w"))
plt.pie(inner, labels=inner_labels, radius=0.7,
wedgeprops=dict(width=0.3, edgecolor="w"))
plt.title("Nested Pie")
plt.show()Subplots & Layouts
plt.subplot
plt.subplot(rows, cols, index) selects the (1-indexed) axes in a grid — the legacy MATLAB-style way. The index runs left-to-right, top-to-bottom. It mutates the 'current axes', so subsequent plt calls draw there. Prefer plt.subplots() for new code; it is cleaner and returns real Axes objects.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 5, 50)
# subplot(nrows, ncols, index) -- 1-indexed!
plt.subplot(2, 2, 1)
plt.plot(x, x)
plt.subplot(2, 2, 2)
plt.plot(x, x**2)
plt.subplot(2, 2, 3)
plt.plot(x, x**3)
plt.subplot(2, 2, 4)
plt.plot(x, np.sin(x))
plt.tight_layout()
plt.show()plt.subplots
plt.subplots(nrows, ncols) returns (fig, axes) where axes is a NumPy array of Axes — index it like axes[row, col]. sharex/sharey align ticks and save space. fig.suptitle() sets a figure-level title above all subplots. Always plt.tight_layout() to avoid label overlap.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 2*np.pi, 100)
# one figure, 2x2 grid of Axes, shared x and y
fig, axes = plt.subplots(2, 2, figsize=(8, 6),
sharex=True, sharey=True)
axes[0, 0].plot(x, np.sin(x)); axes[0, 0].set_title("sin")
axes[0, 1].plot(x, np.cos(x)); axes[0, 1].set_title("cos")
axes[1, 0].plot(x, np.tan(x)); axes[1, 0].set_title("tan")
axes[1, 1].plot(x, -np.sin(x)); axes[1, 1].set_title("-sin")
fig.suptitle("Trig Family")
plt.tight_layout()
plt.show()GridSpec
GridSpec gives full control over subplot sizes — you can merge cells with slicing (gs[0, :] = whole row, gs[1:, 2] = tall right column). Use width_ratios/height_ratios to make columns/rows of unequal size. This is the standard tool for complex dashboards.
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
fig = plt.figure(figsize=(8, 6))
gs = gridspec.GridSpec(3, 3, figure=fig)
ax1 = fig.add_subplot(gs[0, :]) # full top row
ax2 = fig.add_subplot(gs[1, :2]) # middle, left 2 cols
ax3 = fig.add_subplot(gs[1:, 2]) # right, spans 2 rows
ax4 = fig.add_subplot(gs[2, 0:2]) # bottom-left
ax1.set_title("wide top")
ax3.set_title("tall right")
plt.tight_layout()
plt.show()Sharing Axes
sharex=True (or sharey=True) links axes so panning/zooming one updates the others — great for stacked time series. It also hides redundant tick labels on inner axes. The shared groups are accessible via ax.get_shared_x_axes(); you can remove an axes from the group to decouple it.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 100)
fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True)
ax1.plot(x, np.sin(x))
ax2.plot(x, np.cos(x))
# zooming on one now zooms both
ax1.set_xlim(0, 5)
# decouple later if needed
# ax2.get_shared_x_axes().remove(ax2)
fig.suptitle("Shared x-axis")
plt.show()Tight Layout & Constrained
tight_layout() adjusts spacing once to prevent label overlap, but it does not know about fig.suptitle — pass rect to reserve space. layout='constrained' (or fig=Figure(layout='constrained')) is the modern alternative that recalculates on every draw, handles suptitle and colorbars correctly, and is recommended for new code.
import matplotlib.pyplot as plt
# tight_layout: simple, adjusts subplot spacing
fig, axes = plt.subplots(2, 2)
for ax in axes.flat:
ax.set_xlabel("x label")
ax.set_ylabel("y label")
ax.set_title("a title")
fig.suptitle("tight_layout")
plt.tight_layout()
# leave room for suptitle
plt.tight_layout(rect=[0, 0, 1, 0.95])
plt.show()
# constrained_layout: recomputes on every draw (more robust)
fig, axes = plt.subplots(2, 2, layout="constrained")
fig.suptitle("constrained_layout")
plt.show()Legends & Annotations
Basic Legend
plt.legend() collects labels from each plotted artist. loc sets the in-axes position ('upper right', 'lower left', 'best', ...). For an external legend, combine bbox_to_anchor=(x, y) (axes-fraction coords) with loc to anchor that corner of the legend box to that point.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 1, 30)
plt.plot(x, x, label="linear")
plt.plot(x, x**2, label="quadratic")
plt.plot(x, x**3, label="cubic")
plt.legend()
plt.show()
# legend outside the axes
plt.figure()
plt.plot(x, x, label="linear")
plt.plot(x, x**2, label="quadratic")
plt.legend(loc="upper left", bbox_to_anchor=(1, 1))
plt.tight_layout()
plt.show()Legend Position & Columns
ncol lays out legend entries in multiple columns — useful when you have many short labels. frameon/framealpha/fancybox control the legend box appearance. title adds a heading. For a borderless look set frameon=False; this often looks cleaner in publications.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 1, 30)
for i in range(1, 6):
plt.plot(x, x**i, label=f"x^{i}")
plt.legend(loc="upper center", ncol=3, fontsize=9,
frameon=True, framealpha=0.9,
title="Powers", title_fontsize=10)
plt.title("Custom Legend")
plt.show()Annotate Points
ax.annotate(text, xy, xytext) places text at xytext and (optionally) draws an arrow to xy. xy is in data coordinates by default. arrowprops is a dict of arrow styling; arrowstyle='->' is the most common. annotate is far more flexible than plt.text for callouts.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 2*np.pi, 100)
y = np.sin(x)
plt.plot(x, y)
# mark and label the maximum
idx = np.argmax(y)
plt.annotate(f"max = {y[idx]:.2f}",
xy=(x[idx], y[idx]), # point to annotate
xytext=(x[idx]-1, 0.5), # text position
arrowprops=dict(arrowstyle="->"))
plt.show()Annotate with Arrows
arrowprops styles the connector: arrowstyle ('->', '-|>', '<->'), color, lw. connectionstyle bends the arrow — 'arc3,rad=0.3' curves it, 'angle3' makes a right-angle elbow, 'bar' adds a T-bar. These let you build clear callouts that route around data without overlapping curves.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(-3, 3, 100)
plt.plot(x, x**3 - 3*x)
plt.annotate("local min", xy=(1, -2), xytext=(2, -10),
arrowprops=dict(arrowstyle="->", color="red",
lw=2, connectionstyle="arc3,rad=0.3"))
plt.annotate("local max", xy=(-1, 2), xytext=(-3, 8),
arrowprops=dict(arrowstyle="-|>", color="blue",
connectionstyle="angle3"))
plt.show()Custom Legend Handles
When the default legend (built from plotted artists) is not what you want, build your own handles with Line2D, Patch or Line2D-with-no-data and pass handles= to legend(). This is essential for legends that combine lines, patches and scatter markers in a single key.
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
plt.plot([1, 2, 3], [1, 4, 9], "o-", color="tab:blue")
plt.plot([1, 2, 3], [1, 8, 27], "s--", color="tab:orange")
custom = [
Line2D([0], [0], color="tab:blue", marker="o", label="squared"),
Line2D([0], [0], color="tab:orange", marker="s", ls="--", label="cubed"),
Patch(facecolor="gray", label="reference band"),
]
plt.legend(handles=custom)
plt.show()Styles, Colors & rcParams
Built-in Styles
plt.style.use(name) applies a preset look globally; plt.style.context(name) applies it temporarily inside a with-block. Popular styles include 'ggplot', 'seaborn-v0_8-*', 'bmh', 'fivethirtyeight'. Note Seaborn styles were renamed with a '-v0_8' suffix in Matplotlib 3.6+.
import matplotlib.pyplot as plt
# list all available styles
print(plt.style.available)
# use a style globally
plt.style.use("seaborn-v0_8-whitegrid")
# or as a context manager (temporary)
with plt.style.context("dark_background"):
plt.figure()
plt.plot([1, 2, 3], [1, 4, 9])
plt.title("dark")
plt.show()
plt.plot([1, 2, 3], [9, 4, 1])
plt.title("default again")
plt.show()Colormaps
Colormaps fall into three families: sequential (viridis, plasma) for ordered 0..N data, diverging (coolwarm, RdBu) for data with a meaningful midpoint (use with vmin/vmax centered on 0), and qualitative (Set2, tab10) for distinct categories. Prefer perceptually-uniform maps like viridis — they are colorblind-safe and print legibly.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 100)
y = np.sin(x)
# sequential, diverging, qualitative
for cmap in ["viridis", "plasma", "coolwarm", "RdBu", "Set2"]:
plt.scatter(x, y, c=x, cmap=cmap, label=cmap)
plt.colorbar(label=cmap)
plt.title(cmap)
plt.show()Custom Color Cycle
axes.prop_cycle (a cycler object) defines the sequence of properties cycled through as you add lines. You can cycle color, linestyle, marker and more — combine with cycler(color=[...]) + cycler(linestyle=[...]) to advance both simultaneously. This gives every plot a consistent, branded palette.
import matplotlib.pyplot as plt
from cycler import cycler
# set the default color cycle for new axes
plt.rcParams["axes.prop_cycle"] = cycler(
color=["#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00"])
for i in range(1, 6):
plt.plot([0, 1, 2], [i]*3, label=f"line {i}", lw=4)
plt.legend()
plt.title("Custom Cycle")
plt.show()rcParams
rcParams is the master configuration dictionary controlling every default — figure size, DPI, fonts, line widths, grid, savefig options. Set values once at the top of a script for consistent output. plt.rcParams.update({...}) sets several at once. Use mpl.rcParamsFile or a matplotlibrc file to make defaults permanent.
import matplotlib.pyplot as plt
import matplotlib as mpl
# read / set individual parameters
mpl.rcParams["figure.figsize"] = (8, 4)
mpl.rcParams["figure.dpi"] = 100
mpl.rcParams["font.size"] = 12
mpl.rcParams["axes.grid"] = True
mpl.rcParams["axes.grid.which"] = "both"
mpl.rcParams["savefig.bbox"] = "tight"
# update many at once
plt.rcParams.update({
"axes.titlesize": "large",
"axes.labelsize": "medium",
"lines.linewidth": 2,
})
plt.plot([1, 2, 3])
plt.title("Configured Defaults")
plt.show()Color Formats
matplotlib.colors provides conversion (to_hex, to_rgb), named color lists, and colormap construction. LinearSegmentedColormap.from_list builds a continuous colormap from a few anchor colors. For categorical custom maps use ListedColormap(['#aaa', '#bbb', ...]). These let you match brand palettes exactly.