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_titlebelongs to one panel;fig.suptitleto 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)| Transform | Units | Use for |
|---|---|---|
ax.transData | Data values | Pointing at a feature |
ax.transAxes | 0-1 within the panel | Corner labels, panel letters |
fig.transFigure | 0-1 within the figure | Footnotes and sources |
ax.transAxes + clip off | 0-1, drawn outside | Badges 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.Related
Chart types: bar, scatter, histogram, pie and box Publication quality: DPI, vector formats and layout
Last refreshed 2026-09-18.