Text, arrows and reference lines - saying what the chart is for.
Overview
text
Annotations are the text and arrows you add to a chart to explain what the data shows. A peak, a dip, a threshold, a label on a specific point. Without them, the reader has to find the noteworthy values on their own. With them, the chart tells its own story.
matplotlib provides two main tools for placing text on a figure. ax.text places a string at a point. ax.annotate places a string near a point and optionally draws an arrow from the text to the point. Both accept data coordinates, axes coordinates or figure coordinates, so you can anchor text to the data or to a fixed position on the chart.
Annotations are the difference between a chart that shows data and a chart that communicates a finding. A line chart of monthly revenue is informative. The same chart with an annotation saying "new pricing launched here" is actionable.
This page covers text, annotate, coordinate systems, arrow styles, bounding boxes, multi-line text, mathematical notation, and the practical question of where to put text so it does not cover data. It also covers fig.text for text that belongs to the figure rather than to any axes.
Placement is the hardest part. Text that covers data is worse than no text. The textcoords and xytext parameters in annotate let you offset the label from the point. For automated placement on many points, the adjustText library resolves overlaps, but manual placement is often better for a small number of annotations.
The coordinate system matters more than it might seem. Data coordinates move when the axis limits change or when new data is added. Axes coordinates stay fixed relative to the plotting area. A watermark or a "draft" stamp belongs in axes coordinates. A label on a specific data point belongs in data coordinates.
ax.text(x, y, "some words") places text at a point in data coords.
ha (flat alignment) and va (vertical alignment) decide which part of the text sits at that point: ha="center" centres it, va="bottom" puts its bottom edge there. Without them, text starts at the point and runs to the right. This Is rarely where you want it.
Because the coords are data units, the text moves when the axis limits change. Correct for labelling a data point, wrong for a caption.
Worth knowing
ax.text(x, y, s) places text at a data coordinate; ha and va decide which part of the text sits there.
annotate takes xy (the point) and xytext (the label), with arrowprops to join them.
axhline, axvline and axhspan span the axes regardless of limits, and stay correct when the data changes.
transform=ax.transAxes uses 0–1 axes fractions, so a corner label stays in the corner whatever the data does.
fill_between(..., where=cond) shades only the parts meeting a condition; add interpolate=True to stop at the crossing.
textcoords="offset points" shifts a label a fixed distance from its point, which survives changes to the limits.
Annotating a Plot
Text, arrows and link lines - saying what the chart is for.
text puts a string at a data point
Coordinates are in data units unless you say otherwise.
example_01.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.arange(1, 13)
y = np.array([12, 15, 14, 18, 22, 25, 24, 27, 23, 20, 18, 21])
fig, ax = plt.subplots(figsize=(6.5, 3.2))
ax.plot(x, y, marker="o")
peak = y.argmax()
ax.text(x[peak], y[peak] + 1, "peak: %d" % y[peak], ha="center", fontsize=10)
print("text(x, y, s) places s at the DATA coordinate (x, y).")
print("ha / va control which part of the text sits there:")
print(" ha = left | center | right")
print(" va = top | center | bottom | baseline")
print()
print("Without them the text starts at the point and runs right, which")
print("is rarely where you want it.")
Output
annotate draws the arrow too
Two coordinates: what you are pointing at, and where the label goes.
example_02.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 200)
y = np.sin(x) * np.exp(-x / 6)
fig, ax = plt.subplots(figsize=(6.5, 3.2))
ax.plot(x, y)
i = y.argmin()
ax.annotate("lowest point",
xy=(x[i], y[i]),
xytext=(x[i] + 1.5, y[i] - 0.25),
arrowprops=dict(arrowstyle="->", color="crimson"),
fontsize=10)
print("xy = the point being annotated")
print("xytext = where the text sits")
print("arrowprops = how they are joined; omit it for no arrow")
print()
print("arrowstyle: '->', '-|>', 'fancy', 'wedge'")
print("connectionstyle='arc3,rad=0.3' curves the arrow, which helps when")
print("a straight one would cross the data.")
Output
Reference lines
A threshold or a mean, drawn across the whole axes.
example_03.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(0)
x = np.arange(40)
y = rng.normal(100, 15, 40)
fig, ax = plt.subplots(figsize=(6.5, 3.2))
ax.plot(x, y, marker="o", markersize=4)
ax.axhline(y.mean(), color="crimson", linestyle="--", linewidth=1,
label="mean %.1f" % y.mean())
ax.axvline(20, color="grey", linestyle=":", linewidth=1)
ax.axhspan(y.mean() - y.std(), y.mean() + y.std(),
color="crimson", alpha=0.08, label="+/- 1 sd")
ax.legend()
print("axhline / axvline span the whole axes regardless of the limits.")
print("axhspan / axvspan shade a band.")
print()
print("These stay correct when the data changes, which a hard-coded")
print("plot([0, 40], [mean, mean]) does not.")
Output
ax.axhline(value) draws a flat line across the whole axes. axvline the vertical equal. They span the full width even so of the current limits. Keep doing so if the limits change.
axhspan(lo, hi) and axvspan shade a band between two values.
These are better than plotting the line yourself. ax.plot([0, 40], [mean, mean]) hard-codes the x range. Stops spanning the axes as soon as the data grows.
Common uses: a target or threshold, a mean, a standard-deviation band, the date of a known event.
Label them — either through label= and a legend, or with text at one end — because an unexplained line is a question rather than an answer.
Coordinate systems
Data units, axes fractions, or figure fractions - and when each is right.
example_04.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 100)
fig, ax = plt.subplots(figsize=(6.5, 3.2))
ax.plot(x, np.sin(x))
ax.text(5, 0.5, "data coords (5, 0.5)", ha="center", color="crimson")
ax.text(0.02, 0.95, "axes coords (0.02, 0.95)", transform=ax.transAxes,
va="top", color="steelblue")
fig.text(0.5, 0.01, "figure coords - bottom centre", ha="center", fontsize=8)
print("default : data coordinates, so the text moves if the")
print(" limits change")
print("transform=ax.transAxes : 0-1 across the axes, so a corner label")
print(" stays in the corner whatever the data")
print("fig.text : 0-1 across the whole figure")
print()
print("A caption or a note belongs in axes or figure coordinates. A")
print("label for a specific point belongs in data coordinates.")
Output
Three systems, and choosing right is what makes annotations live changes to the data.
Data coords (the default) — for anything attached to a exact value.
Axes coords — transform=ax.transAxes, running 0 to 1 across the axes. A label at (0.02, 0.95) sits just inside the top-left corner what the data is. This is right for a panel letter, a note, a sample size.
Figure coords — fig.text(0.5, 0.01, ...), running 0 to 1 across the whole figure. Right for a source note or a caption under a grid of panels.
The mistake is putting a corner label in data coords. It looks correct until the data changes, then drifts into the middle of the plot or off the edge fully.
Highlighting a region
Shading is quieter than an arrow and often says more.
example_05.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.arange(60)
rng = np.random.default_rng(1)
y = rng.normal(0, 1, 60).cumsum() + 20
fig, ax = plt.subplots(figsize=(7, 3.2))
ax.plot(x, y, color="0.3")
ax.axvspan(24, 34, color="crimson", alpha=0.12)
ax.text(29, ax.get_ylim()[1], "incident", ha="center", va="top",
fontsize=9, color="crimson")
above = y > y.mean()
ax.fill_between(x, y.mean(), y, where=above, alpha=0.25, color="steelblue")
print("axvspan marks a period. fill_between with `where` shades only")
print("the parts meeting a condition.")
print()
print("where= needs interpolate=True if you want the shading to stop")
print("exactly at the crossing rather than at the nearest data point.")
Output
Restraint
Every annotation competes with the data for attention.
example_06.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.arange(1, 13)
y = np.array([12, 15, 14, 18, 22, 25, 24, 27, 23, 20, 18, 21])
fig, (a, b) = plt.subplots(1, 2, figsize=(10, 3.4))
a.plot(x, y, marker="o")
for xi, yi in zip(x, y):
a.annotate(str(yi), (xi, yi), textcoords="offset points",
xytext=(0, 6), ha="center", fontsize=8)
a.set_title("every point labelled")
b.plot(x, y, marker="o", color="0.6")
i = y.argmax()
b.plot([x[i]], [y[i]], marker="o", color="crimson", markersize=9)
b.annotate("peak, %d" % y[i], (x[i], y[i]), textcoords="offset points",
xytext=(8, 4), color="crimson", fontsize=10)
b.set_title("one point highlighted")
print("Twelve labels are twelve things to read. One is a message.")
print()
print("textcoords='offset points' with xytext=(dx, dy) shifts the label")
print("by a fixed distance from the point, which keeps the gap constant")
print("whatever the axis limits are.")
Output
This is the part that matters most and is hardest to apply.
each annotation competes with the data for focus. A chart with twelve labelled points has twelve things to read and no message. A chart with one highlighted point and one label has a message.
The handy question is what the reader should take away. Then annotating that and nothing else. Greying the rest of the series and colouring the highlighted part is often more good than adding an arrow. It directs focus without adding ink.
If a few things truly need pointing out, that is often a sign the chart is trying to say more than one thing. Two charts would each say theirs more clearly.
annotate
ax.annotate is text with two spots and an optional arrow:
ax.annotate("lowest point",
xy=(x, y), # the point
xytext=(x + 1, y - 0.3), # the label
arrowprops=dict(arrowstyle="->"))
Omit arrowprops and it's text with an offset. Include it and matplotlib draws a connector.
arrowstyle takes "->", "-|>", "fancy", "wedge" and others. connectionstyle="arc3,rad=0.3" curves the arrow. This Is how you route it around the data rather than through it.
textcoords="offset points" with xytext=(dx, dy) spots the label a fixed number of points from the target, instead of at another data coord. That is often what you want. The gap stays constant when the limits change. Here A data-coord offset would grow or shrink.
Shading regions
fill_between(x, y1, y2) fills between two curves, or between a curve and a constant.
where=rule restricts it to the parts where a boolean array is true. Shading only where a series is above its mean, or above a threshold.
Add interpolate=True and the shading stops just at the crossing point. Without it, it stops at the nearest data point, leaving a small notch. On coarsely sampled data that notch is visible and looks like a mistake.
fill_between is also how you draw a trust band, with the upper and lower bounds as the two curves and alpha around 0.2.
Boxes behind text
bbox= puts a patch behind a label. This Is how you keep text readable over busy data:
ax.text(x, y, "peak", bbox=dict(boxstyle="round,pad=0.3",
facecolor="white", alpha=0.8, edgecolor="none"))
boxstyle takes "round", "square", "larrow" and others, with pad controlling the margin.
A white box at 80% alpha is the standard treatment for a label that must sit over a line or a filled region. Without it, text over data is legible in the draft and unreadable once the data changes.
Annotating a specific series
Text placed at the end of a line is the most handy annotation there's. The spot should come from the data:
Taking the colour from the artist ties the label to its line without repeating a colour constant. It keeps working when the cycle changes.
The leading spaces are a crude but good offset. annotate with textcoords="offset points" is the tidier version.
Leave room for the labels with ax.set_xlim(right=x.max() * 1.15), or they run off the edge.
Arrows
arrowprops is a dict, and the two handy spellings are:
dict(arrowstyle="->") — the modern form, with styles like "->", "-|>", "fancy".
dict(facecolor="black", shrink=0.05) — the older form. This Makes a filled arrow.
connectionstyle="arc3,rad=0.2" curves the connector. A slight curve often reads better than a straight line. It doesn't look like part of the data.
shrinkA and shrinkB pull the ends back from the text and the target. This Stops the arrowhead touching the point it's finding.
Guides and callouts
A few patterns recur often enough to be worth naming.
A threshold with a label at the end: axhline plus text at the right edge in axes coordinates for x and data coordinates for y — ax.text(1.01, value, "target", transform=ax.get_yaxis_transform()).
A shaded period with a caption at the top: axvspan plus text at ax.get_ylim()[1] with va="top".
A value callout: a single marker in a strong colour, plus text offset from it.
ax.get_yaxis_transform() is the blended transform used above: x in axes fractions, y in data units. Its counterpart get_xaxis_transform() does the reverse. Between them they place edge labels that stay put when the data changes.
Too much of it
The failure mode of annotation is a chart where all is emphasised. This Is the same as nothing being emphasised.
A handy discipline is to write the sentence the chart is meant to support. Then annotate only what that sentence refers to. If the sentence has two clauses about other things, that is two charts.
Annotation as editing
The most handy way to think about annotation is as editing rather than addition.
A chart shows all in the data equally. Annotation is where you say which part matters. And the strongest form of that is often subtraction, not addition. Greying the context, thinning the lines that aren't the subject, dropping the gridlines that aren't being read.
A highlighted line with a label at its end and all else in grey carries more meaning than the same chart with an arrow and a paragraph of text. The emphasis is in the visual tree rather than in a thing extra to read.
Add annotation when it names a thing the reader couldn't derive. Drop weight from all that isn't it.
Keeping annotations correct
Annotations placed by hand go stale. The data changes and the coords don't.
Three habits keep them honest:
Find the spot from the data.ax.annotate(..., xy=(x[i], y[i])) with i = y.argmax() follows the peak wherever it moves.
Find the text from the data.f"peak {y.max():.1f}" can't disagree with the chart.
Use axes coords for anything not attached to a value. A corner note in data coords drifts as soon as the limits change.
A hard-coded annotation is correct just once. There's nothing to warn you when it stops being.
Annotating for different readers
How much annotation a chart needs depends fully on who reads it and how long they've.
A chart in a presentation gets one annotation, large, saying the thing the speaker is about to say. all else is removed. The audience has seconds and can't re-read.
A chart in a report can carry two or three. The reader controls the pace and can look between the chart and the text.
A chart in an appendix may carry none. Its job is to be on hand rather than to argue.
A chart for yourself needs none at all.
The common error is annotating a presentation chart like a report chart. Four callouts, a legend, a subtitle and a source line, none of which can be read from the back of a room.
Writing the sentence the chart supports. Then annotating only the words in that sentence, resolves it in each case.
Annotating a Plot, in summary
text places a string at a data coord, with ha and va deciding which part of it sits there.
annotate adds a second spot and an optional arrow. textcoords="offset points" keeps the gap constant when the limits change.
axhline, axvline and the span functions cover thresholds and periods. Stay correct when the data grows.
transform=ax.transAxes is for anything not attached to a value. A corner note stays in the corner.
fill_between(..., where=...) shades a rule, with interpolate=True to stop at the crossing.
And the hardest part is restraint: each annotation competes with the data. A chart with one highlighted point says more than one with twelve labels.
Text that scales
An annotation sized in points stays the same physical size when the figure changes. This Means it occupies a other fraction of a small figure than a large one.
For a figure that will be made at a few sizes, three options:
Set font sizes in the style scaled to a base font.size. Changing one value scales all together.
Find sizes from the figure size. This Is what a house function can do: fontsize = 4 + fig.get_size_inches()[0].
Draw at the final size and avoid the problem. This Is the recommendation all over else in this track.
The failure to avoid is annotating a figure at screen size and then exporting it at a third of that for a doc. Here A comfortable label turns unreadable and an arrow turns a hairline.
Looking at the exported file, at the size it will be seen, is the check that catches all of it.
One more thing
ax.annotate accepts xycoords and textcoords. So The point being annotated can itself be in axes or figure coords rather than data ones.
That is how you draw an arrow from a corner note to a data point. Text anchored to the corner in axes coords, target in data coords. Which stays correct as the data changes.
Annotating a Plot: the short version
Annotation is where a chart stops showing and starts saying.
The technique is straightforward. The discipline isn't. One thing pointed out clearly beats five things labelled. Greying the context is often more good than adding an arrow.
Questions people ask about Annotating a Plot
What is the difference between ax.text and ax.annotate?text places words at a point. annotate can also draw an arrow from the text to a different point.
What coordinates does annotation use? Data coordinates by default. Use xycoords='axes fraction' to position relative to the axes box, which survives changes to the data range.
How do I draw a reference line? Use ax.axhline or ax.axvline for lines across the axes, and ax.axhspan or ax.axvspan to shade a band.
How many annotations are too many? If readers must read each one to understand the chart, there are too many. Annotate the one or two points that carry the message.
Why does my annotation move when I resize the figure? Offsets given in points stay fixed, while data coordinates move with the axes. Choose the coordinate system that matches what the note is attached to.
How do I stop labels overlapping? Offset each with xytext and textcoords='offset points', or use a helper library such as adjustText for many labels.
Reading the code back
An annotation has three parts: what is being pointed at. Here The label sits. Which coord system each uses. Getting the third right is what makes the annotation live a change in the data. It's the part most often left to chance. finding both the spot and the text from the data means the chart can't contradict itself.
Check yourself
0 of 4
Answer without scrolling back up.
What do `ha` and `va` control in `ax.text`?
Without them, text starts at the point and runs right - rarely where you want it.
What are `xy` and `xytext` in `annotate`?
arrowprops joins them; omit it for text with an offset and no arrow.
Why use `axhline` rather than plotting the line yourself?
ax.plot([0, 40], [mean, mean]) hard-codes the x range and stops spanning as soon as the data grows.
Where should a corner note like a sample size be placed?
A corner label in data coordinates looks right until the data changes, then drifts into the plot or off the edge.
Cheat sheet
Annotating a Plot
Add text labels, arrows and reference lines to a matplotlib chart. Turn a bare plot into one that tells the reader what to notice.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.