Text, arrows and reference lines - saying what the chart is for.
Overview
text
ax.text(x, y, "some words") places text at a point in data coordinates.
ha (horizontal 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, which is rarely where you want it.
Because the coordinates 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 reference 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
Output
annotate draws the arrow too
Two coordinates: what you are pointing at, and where the label goes.
example_02.pymatplotlib
Output
Reference lines
A threshold or a mean, drawn across the whole axes.
example_03.pymatplotlib
Output
ax.axhline(value) draws a horizontal line across the whole axes; axvline the vertical equivalent. They span the full width regardless of the current limits, and 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, and stops spanning the axes as soon as the data grows.
Typical 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
Output
Three systems, and choosing correctly is what makes annotations survive changes to the data.
Data coordinates (the default) — for anything attached to a specific value.
Axes coordinates — transform=ax.transAxes, running 0 to 1 across the axes. A label at (0.02, 0.95) sits just inside the top-left corner whatever the data is. This is right for a panel letter, a note, a sample size.
Figure coordinates — 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 coordinates: it looks correct until the data changes, then drifts into the middle of the plot or off the edge entirely.
Highlighting a region
Shading is quieter than an arrow and often says more.
example_05.pymatplotlib
Output
Restraint
Every annotation competes with the data for attention.
example_06.pymatplotlib
Output
This is the part that matters most and is hardest to apply.
Every annotation competes with the data for attention. 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 useful question is what the reader should take away, and then annotating that and nothing else. Greying the rest of the series and colouring the highlighted part is often more effective than adding an arrow, because it directs attention without adding ink.
If several things genuinely need pointing out, that is usually a sign the chart is trying to say more than one thing, and two charts would each say theirs more clearly.
annotate
ax.annotate is text with two positions 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 is 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, which is how you route it around the data rather than through it.
textcoords="offset points" with xytext=(dx, dy) positions the label a fixed number of points from the target, instead of at another data coordinate. That is usually what you want: the gap stays constant when the limits change, where a data-coordinate offset would grow or shrink.
Shading regions
fill_between(x, y1, y2) fills between two curves, or between a curve and a constant.
where=condition 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 exactly 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 confidence 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, which 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 useful annotation there is, and the position should come from the data:
Taking the colour from the artist ties the label to its line without repeating a colour constant, and it keeps working when the cycle changes.
The leading spaces are a crude but effective 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 useful spellings are:
dict(arrowstyle="->") — the modern form, with styles like "->", "-|>", "fancy".
dict(facecolor="black", shrink=0.05) — the older form, which produces a filled arrow.
connectionstyle="arc3,rad=0.2" curves the connector. A slight curve often reads better than a straight line, because it does not look like part of the data.
shrinkA and shrinkB pull the ends back from the text and the target, which stops the arrowhead touching the point it is identifying.
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, and 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 everything is emphasised, which is the same as nothing being emphasised.
A useful discipline is to write the sentence the chart is meant to support, and then annotate only what that sentence refers to. If the sentence has two clauses about different things, that is two charts.
Annotation as editing
The most useful way to think about annotation is as editing rather than addition.
A chart shows everything in the data equally. Annotation is where you say which part matters — and the strongest form of that is usually subtraction, not addition: greying the context, thinning the lines that are not the subject, removing the gridlines that are not being read.
A highlighted line with a label at its end and everything else in grey carries more meaning than the same chart with an arrow and a paragraph of text, because the emphasis is in the visual hierarchy rather than in something extra to read.
Add annotation when it names something the reader could not derive; remove weight from everything that is not it.
Keeping annotations correct
Annotations placed by hand go stale, because the data changes and the coordinates do not.
Three habits keep them honest:
Compute the position from the data.ax.annotate(..., xy=(x[i], y[i])) with i = y.argmax() follows the peak wherever it moves.
Compute the text from the data.f"peak {y.max():.1f}" cannot disagree with the chart.
Use axes coordinates for anything not attached to a value. A corner note in data coordinates drifts as soon as the limits change.
A hard-coded annotation is correct exactly once, and there is nothing to warn you when it stops being.
Annotating for different readers
How much annotation a chart needs depends entirely on who reads it and how long they have.
A chart in a presentation gets one annotation, large, saying the thing the speaker is about to say. Everything else is removed, because the audience has seconds and cannot re-read.
A chart in a report can carry two or three, because the reader controls the pace and can look between the chart and the text.
A chart in an appendix may carry none, because its job is to be available 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, and then annotating only the words in that sentence, resolves it in every case.
In summary
text places a string at a data coordinate, with ha and va deciding which part of it sits there.
annotate adds a second position and an optional arrow, and textcoords="offset points" keeps the gap constant when the limits change.
axhline, axvline and the span functions cover thresholds and periods, and stay correct when the data grows.
transform=ax.transAxes is for anything not attached to a value, so a corner note stays in the corner.
fill_between(..., where=...) shades a condition, with interpolate=True to stop at the crossing.
And the hardest part is restraint: every annotation competes with the data, and 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, which means it occupies a different fraction of a small figure than a large one.
For a figure that will be produced at several sizes, three options:
Set font sizes in the style relative to a base font.size, so changing one value scales everything together.
Compute sizes from the figure size, which is what a house function can do: fontsize = 4 + fig.get_size_inches()[0].
Draw at the final size and avoid the problem, which is the recommendation everywhere 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 document, where a comfortable label becomes unreadable and an arrow becomes 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 as well as textcoords, so the point being annotated can itself be in axes or figure coordinates 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 coordinates, target in data coordinates — which stays correct as the data changes.
The short version
Annotation is where a chart stops showing and starts saying.
The technique is straightforward; the discipline is not. One thing pointed out clearly beats five things labelled, and greying the context is usually more effective than adding an arrow.
Reading the code back
An annotation has three parts: what is being pointed at, where the label sits, and which coordinate system each uses. Getting the third right is what makes the annotation survive a change in the data, and it is the part most often left to chance. Computing both the position and the text from the data means the chart cannot 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
ha (horizontal 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, which is rarely where you want it.
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.