PYTHON / MATPLOTLIB
Labels, legends, ticks, and annotations
Label axes, control what a legend shows, set tick positions and their text format, and attach arrow annotations to specific data points.
What you will learn
- Pass label= to each plotting call and call ax.legend() only after plotting
- Ticks come from a Locator (positions) plus a Formatter (text), set them separately
- Use ax.annotate(xy=..., xytext=..., textcoords='offset points') to point at data
- Use transform=ax.transAxes when text must stay in a corner for any data range
Understanding Labels, legends, ticks, and annotations
Every piece of text in a figure belongs to some object, and you configure that object rather than drawing text by hand. Axis labels and the title belong to the Axes (ax.set_xlabel, ax.set_ylabel, ax.set_title, plus fig.suptitle for a figure-wide heading), and the underlying Text artists stay reachable as ax.xaxis.label and ax.title if you want to restyle them later. This matters for layout: tight_layout and bbox_inches='tight' know how much room an axis label needs, whereas a loose ax.text placed by hand gets no such reservation and can be cut off.
Tick text is produced by two collaborating objects on the Axis: a Locator decides where ticks go and a Formatter turns each position into a string. ax.set_xticks(positions) installs a FixedLocator, and ax.set_xticklabels(strings) installs a FixedFormatter over whatever positions currently exist, which is why calling the second one alone is fragile: the strings become bound to tick slots the automatic locator happened to choose. ax.tick_params is a different axis entirely of control: it changes rotation, size, length, direction and which=('major'|'minor'|'both') without touching the label content.
A legend is assembled, not written. Each artist carries a label, ax.legend() collects the labelled ones in creation order, and labels beginning with an underscore are deliberately skipped so helper lines stay out. Passing handles and labels explicitly lets you reorder, drop, or merge entries. Annotations work with two points that can use different coordinate systems: xy is the thing you are pointing at (usually data coordinates) and xytext is where the text sits, most usefully expressed as an offset in points so the callout keeps its distance from the marker even when the limits change.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
signups = [120, 145, 133, 190, 260, 245]
churn = [30, 28, 41, 35, 52, 48]
x = range(len(months))
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(x, signups, marker="o", label="signups")
ax.plot(x, churn, marker="s", label="churn")
ax.set_xlabel("Month (2024)")
ax.set_ylabel("Accounts")
ax.set_title("Signups vs churn")
ax.set_xticks(list(x))
ax.set_xticklabels(months)
ax.set_ylim(0, 300)
ax.set_yticks([0, 50, 100, 150, 200, 250, 300])
ax.tick_params(axis="y", labelsize=9)
peak = signups.index(max(signups))
ax.annotate(f"peak {max(signups)}",
xy=(peak, signups[peak]),
xytext=(peak - 1.8, signups[peak] + 25),
arrowprops=dict(arrowstyle="->"))
leg = ax.legend(loc="upper left", title="series", frameon=False)
print("x tick text:", [t.get_text() for t in ax.get_xticklabels()])
print("y tick values:", [int(v) for v in ax.get_yticks()])
print("legend labels:", [t.get_text() for t in leg.get_texts()])
print("legend title:", leg.get_title().get_text())
print("texts on axes:", len(ax.texts))
fig.savefig("signups.png", dpi=100, bbox_inches="tight")Plot text is configured through its owner: the Axes for labels, the Axis locator/formatter pair for ticks, artist labels for the legend, and a chosen coordinate system for annotations.
Worked examples
Locators, formatters, and tick cosmetics
Replaces numeric ticks with quarter names, formats the y axis as percentages, and rotates the x labels.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.ticker import PercentFormatter, MultipleLocator
fig, ax = plt.subplots()
ax.plot([0, 1, 2, 3], [0.12, 0.31, 0.44, 0.58], marker="o")
ax.set_xlim(0, 3)
ax.set_xticks([0, 1, 2, 3], ["Q1", "Q2", "Q3", "Q4"])
ax.xaxis.set_minor_locator(MultipleLocator(0.5))
ax.set_ylim(0, 0.6)
ax.set_yticks([0, 0.15, 0.30, 0.45, 0.60])
ax.yaxis.set_major_formatter(PercentFormatter(xmax=1.0, decimals=0))
ax.tick_params(axis="x", labelrotation=30, labelsize=9)
ax.tick_params(axis="x", which="minor", length=2)
print("y labels:", [t.get_text() for t in ax.get_yticklabels()])
print("x labels:", [t.get_text() for t in ax.get_xticklabels()])
print("x rotation:", ax.get_xticklabels()[0].get_rotation())Example explained
Line 1set_xticks(positions, labels) (Matplotlib 3.5+) installs the FixedLocator and FixedFormatter together, so positions and text can never drift apart.
Line 2PercentFormatter(xmax=1.0) treats 1.0 as 100 percent, so the stored fraction 0.15 is displayed as '15%' while the data stays a fraction.
Line 3The minor locator puts ticks every 0.5, but minor ticks use a NullFormatter by default, which is why only four label strings exist.
Line 4tick_params only changes appearance: labelrotation reaches the existing Text objects and is remembered for ticks created later.
Choosing what goes in the legend
Hides a reference line from the legend, then reorders the remaining entries by passing handles explicitly.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
x = [0, 1, 2, 3, 4]
fig, ax = plt.subplots()
ax.plot(x, [1, 3, 2, 5, 4], label="model A")
ax.plot(x, [2, 2, 3, 3, 4], label="model B")
ax.axhline(3, color="grey", linestyle=":", label="_target")
handles, labels = ax.get_legend_handles_labels()
print("auto-collected:", labels)
order = [1, 0]
leg = ax.legend([handles[i] for i in order],
[labels[i] for i in order],
loc="lower right", ncol=2, title="run 7")
print("legend order:", [t.get_text() for t in leg.get_texts()])
print("frame drawn:", leg.get_frame_on())Example explained
Line 1get_legend_handles_labels() shows exactly what a bare ax.legend() would pick up; the axhline is skipped because its label starts with an underscore.
Line 2Passing handles and labels positionally overrides the automatic collection, which is the only reliable way to control entry order.
Line 3ncol=2 lays the entries out side by side, which keeps a legend short when it sits under a wide plot.
Line 4leg.get_frame_on() confirms the box is drawn; frameon=False at creation or leg.set_frame_on(False) removes it.
Annotating a specific data point
Points an arrow at a latency spike using an offset in points, and pins a caption to a corner with axes coordinates.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
days = [1, 2, 3, 4, 5, 6, 7]
latency = [120, 118, 125, 260, 130, 122, 119]
fig, ax = plt.subplots()
ax.plot(days, latency, marker="o")
spike = max(range(len(latency)), key=lambda i: latency[i])
ann = ax.annotate(f"deploy regression\n{latency[spike]} ms",
xy=(days[spike], latency[spike]),
xytext=(12, -28),
textcoords="offset points",
ha="left",
arrowprops=dict(arrowstyle="->",
connectionstyle="arc3,rad=-0.3"))
note = ax.text(0.02, 0.95, "p95 latency", transform=ax.transAxes, va="top")
print("anchor point:", ann.xy)
print("text offset:", ann.xyann, ann.anncoords)
print("corner label:", note.get_position(), note.get_transform() is ax.transAxes)Example explained
Line 1xy is read in data coordinates and is where the arrow tip lands, so it moves with the point if the data changes.
Line 2xytext with textcoords='offset points' is measured from xy in typographic points, so the callout keeps the same visual gap at any zoom level or figure size.
Line 3ax.text with transform=ax.transAxes interprets (0.02, 0.95) as axes fractions, keeping the caption in the top-left corner whatever the limits are.
Line 4arrowprops turns the annotation into an arrow, and connectionstyle bends it so the line does not lie on top of the curve.
Important notes
The single-call form ax.set_xticks(positions, labels) needs Matplotlib 3.5 or newer; on older versions use set_xticks followed by set_xticklabels, which is also what avoids the FixedFormatter warning.
Text and annotations do not take part in autoscaling, so a label near the edge can be clipped; widen the limits, add margins, or save with bbox_inches='tight'.
Common mistakes
Calling ax.legend() when no artist was given a label=: Matplotlib warns that no labelled artists were found and draws nothing, which looks like a broken legend rather than missing labels.
Calling ax.set_xticklabels(names) without ax.set_xticks(positions): the strings are attached to whatever positions the automatic locator picked, so you get a count-mismatch error or labels sitting over the wrong data.
Setting ticks outside the current view, e.g. ax.set_yticks([0, ..., 300]) when the data only reaches 260: ticks and any annotation placed up there are simply not drawn, because set_yticks does not change the limits — call ax.set_ylim too.
Try it yourself
Change, predict, then run
Plot [4200, 5100, 4800, 6300, 7100, 6900] for Jan-Jun, replace the numeric x ticks with the month names rotated 45 degrees, and format the y axis with a FuncFormatter that prints values as '4.2k'. Then annotate the maximum month with an arrow whose text is placed using textcoords='offset points'.
Open the Python workspaceCheck your understanding
You plot four bars, then call ax.set_xticklabels(['A', 'B', 'C', 'D']) without setting tick positions. Why is that risky?
- set_xticklabels only accepts numeric values, so the strings are silently ignored.
- The labels are placed at data coordinates 0, 1, 2 and 3 no matter what the axis limits are.
- The strings are pinned to whatever positions the automatic locator happened to choose, so any change of limits or data leaves the labels describing the wrong values.
- Tick labels must be set before the first plotting call, otherwise the plot call overwrites them.
Show answer
set_xticklabels installs a FixedFormatter on top of the existing locator, so the text is bound to tick slots rather than to data values; if the locator later produces different positions, the labels no longer match what is underneath. Option 1 is tempting because in a simple case the locator does pick 0..3 and it looks like data-coordinate placement, but the labels follow tick positions, not data values.