Annotating a Plot

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
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 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
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
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.

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:

ax.text(x[-1], y[-1], f"  {name}", va="center", color=line.get_color())

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.

  1. What do `ha` and `va` control in `ax.text`?

  2. What are `xy` and `xytext` in `annotate`?

  3. Why use `axhline` rather than plotting the line yourself?

  4. Where should a corner note like a sample size be placed?

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.

MATPLOTLIB · vizlearn.in/matplotlib/annotations.html

About the author

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.