Titles, legends, annotations and text

Label axes properly, place legends without fighting the layout, and point at data with annotations in the right coordinate system.

Titles and axis labels

fig, ax = plt.subplots(figsize=(7, 4))

ax.set_title("Revenue by quarter", loc="left", fontsize=13)
ax.set_xlabel("Quarter")
ax.set_ylabel("Revenue (GBP millions)")
ax.set_xticks(range(4), ["Q1", "Q2", "Q3", "Q4"])

fig.suptitle("Annual report", fontsize=15)      # the whole figure
fig.supxlabel("Fiscal year 2026", fontsize=9)
fig.supylabel("All units in GBP", fontsize=9)
  • Every axis needs a unit; a number without a unit is an unfinished chart.
  • Prefer sentence case titles over Title Case With Capitals Everywhere.
  • ax.set_title belongs to one panel; fig.suptitle to the whole figure.
  • Use ax.set_xticks(positions, labels) with explicit positions instead of setting tick labels that may not match.
  • Keep one idea per panel and put the takeaway in the title when the audience is non-technical.

Legend placement

ax.plot(x, y1, label="Actual")
ax.plot(x, y2, label="Forecast")

ax.legend()                                     # default: best free spot
ax.legend(loc="upper left", frameon=False, ncols=2, title="Series")
ax.legend(bbox_to_anchor=(1.02, 1.0), loc="upper left")   # outside, right

# label the line directly when there are only two series
ax.text(x[-1], y1[-1], " Actual", va="center", color="tab:blue")

# select what appears in the legend
handles, labels = ax.get_legend_handles_labels()
ax.legend(handles[:2], labels[:2])

Direct labelling beats a legend for two or three series: the reader never has to match a colour to an entry. A legend is worth its space when the series are numerous or appear in a separate panel.

Annotations and text coordinates

ax.annotate("launch", xy=(3, 40), xytext=(4, 25),
            arrowprops=dict(arrowstyle="->", color="grey", lw=0.8))

# transData: the default, positions follow the data
ax.text(2, 30, "peak", fontsize=9)

# transAxes: 0-1 across the panel, ignores the data range
ax.text(0.02, 0.95, "provisional", transform=ax.transAxes, va="top")

# transFigure: 0-1 across the whole figure
fig.text(0.5, 0.01, "Source: internal data", ha="center", fontsize=8)
TransformUnitsUse for
ax.transDataData valuesPointing at a feature
ax.transAxes0-1 within the panelCorner labels, panel letters
fig.transFigure0-1 within the figureFootnotes and sources
ax.transAxes + clip off0-1, drawn outsideBadges just above the axes
💡
Choose the coordinate system by asking what should move. A note about a data point uses transData; a label that should stay in the corner when the limits change uses transAxes.

FAQ

How do I stop a legend covering my data?
Try loc="best", then move it outside with bbox_to_anchor, then just label the lines directly. An outside legend is almost always cleaner than one floating over the plot.
Why did my annotation move when I changed the limits?
It was placed in data coordinates. Use transform=ax.transAxes if it should stay anchored to the panel instead.

Chart types: bar, scatter, histogram, pie and box Publication quality: DPI, vector formats and layout

Last refreshed 2026-09-18.