Matplotlib Artists
In one line: everything drawn is an object with getters and setters — which is why matplotlib never dead-ends.
The idea
Every visible element is an Artist. Plotting functions return the Artists they created, so you can keep them and modify them later.
lines = ax.plot(x, y) # returns a LIST of Line2D
line = lines[0]
line.set_color("crimson")
line.set_linewidth(2)
line.set_linestyle("--")
line.set_alpha(0.7)
line.set_label("treated")
line.set_zorder(10) # draw on top
ax.plot returning a list trips people up — it can plot multiple lines at once. The idiom is line, = ax.plot(...) (note the comma) to unpack a single one.
The Artist families
| Class | What it is | Created by |
|---|---|---|
Line2D |
a line, or markers | ax.plot |
PathCollection |
many markers as one object | ax.scatter |
Rectangle / Patch |
bars, spans, shapes | ax.bar, ax.axvspan |
Text |
any text | ax.set_title, ax.annotate |
AxesImage |
a raster image | ax.imshow |
QuadMesh |
a mesh | ax.pcolormesh, sns.heatmap |
PolyCollection |
filled regions | ax.fill_between |
Legend |
the legend box | ax.legend() |
Spine |
the panel borders | automatic |
Note scatter returns one PathCollection for all points, not one Artist per point. That is why scatter is fast, and why you set properties on the collection as a whole (or with per-point arrays).
Get and set
Every property has a matched pair:
line.get_color(); line.set_color("red")
ax.get_xlim(); ax.set_xlim(0, 10)
text.get_fontsize(); text.set_fontsize(8)
line.set(color="red", lw=2, alpha=0.5) # several at once
plt.getp(line) # list every property
plt.setp(lines, color="grey", alpha=0.3) # set on many Artists at once
plt.getp(artist) is the discovery tool — it prints everything you can change on that object.
Finding Artists after the fact
ax.lines # all Line2D
ax.collections # scatter, fill_between
ax.patches # bars, shapes
ax.texts # annotations
ax.images
ax.get_children() # everything
ax.get_legend()
ax.get_xticklabels()
This is how you modify a plot someone else drew — including one seaborn drew:
ax = sns.boxplot(data=df, x="condition", y="value")
for patch in ax.patches:
patch.set_alpha(0.6)
patch.set_edgecolor("black")
Any seaborn plot is a bag of Artists you can post-process. That is the escape hatch for "seaborn almost does what I want".
zorder — draw order
ax.scatter(x, y, zorder=3)
ax.axhline(0, zorder=1) # behind the points
ax.grid(True, zorder=0)
Higher zorder draws on top. Defaults: images 0, patches 1, lines 2, text 3. The usual need is pushing gridlines behind data — which requires ax.set_axisbelow(True) for gridlines specifically, because they are a special case.
Text and annotation
ax.text(x, y, "TP53", fontsize=8, ha="center", va="bottom")
ax.annotate(
"TP53",
xy=(x, y), # the point
xytext=(x + 0.5, y + 1), # where the label goes
arrowprops=dict(arrowstyle="->", lw=0.8, color="grey"),
fontsize=8,
)
# coordinates relative to the Axes rather than the data
ax.text(0.02, 0.98, "A", transform=ax.transAxes,
fontsize=12, fontweight="bold", va="top")
transform=ax.transAxes uses (0,0)=bottom-left, (1,1)=top-right of the panel, independent of the data range. This is how you place panel labels (A, B, C) consistently across a multi-panel figure.
Matplotlib renders LaTeX-style maths in any text:
ax.set_xlabel(r"log$_2$ fold change")
ax.set_ylabel(r"$-$log$_{10}$ $p_{\mathrm{adj}}$")
ax.set_title(r"$\Delta$ expression")
Use a raw string (r"...") so backslashes survive.
Labelling selected points
The archetypal bioinformatics annotation task:
top = df.nlargest(10, "neglog10p")
for _, row in top.iterrows():
ax.annotate(row["gene"], (row["log2fc"], row["neglog10p"]),
fontsize=7, xytext=(3, 3), textcoords="offset points")
Labels will overlap. The adjustText package repels them automatically and is worth installing:
from adjustText import adjust_text
texts = [ax.text(r.log2fc, r.neglog10p, r.gene, fontsize=7)
for r in top.itertuples()]
adjust_text(texts, ax=ax, arrowprops=dict(arrowstyle="-", lw=0.5, color="grey"))
Legend control
ax.legend(frameon=False, loc="upper right", fontsize=8, ncol=2)
ax.legend(bbox_to_anchor=(1.02, 1), loc="upper left") # outside the panel
ax.legend(title="Condition")
handles, labels = ax.get_legend_handles_labels()
ax.legend(handles[:3], labels[:3]) # subset
from matplotlib.lines import Line2D
custom = [Line2D([0], [0], marker="o", color="w",
markerfacecolor="crimson", label="significant")]
ax.legend(handles=custom)
Building custom handles is how you produce a legend for something matplotlib did not label automatically — e.g. a scatter coloured by a continuous array.
Only artists with a label that does not start with _ appear. ax.plot(x, y, label="_nolegend_") hides one deliberately.
Rasterizing for large scatters
ax.scatter(x, y, s=1, rasterized=True)
fig.savefig("out.pdf", dpi=300)
A vector PDF with 500,000 scatter points is enormous and crashes viewers. rasterized=True renders just that Artist as pixels while keeping text and axes as vectors — small file, editable labels, no crash. This is essential for volcano plots, Manhattan plots and single-cell embeddings.
Common mistakes
- Forgetting
ax.plotreturns a list. Useline, = ax.plot(...). - Modifying an Artist after
savefigand expecting the file to change. - Losing the return value and then trying to find the Artist by digging through
ax.get_children(). - Text in data coordinates when you meant Axes coordinates.
- Overlapping labels. Use
adjustTextor label fewer points. - Non-rasterized huge scatters in a vector output.
zorderwith gridlines — needsax.set_axisbelow(True).- Non-raw strings with LaTeX, so
\Dbecomes an escape sequence.
See also
Figure and Axes · Axes Styling and Annotation · Saving Figures · Colormaps and Color · Applied - Differential Expression Volcano Plot