Matplotlib Artists
Matplotlib Artists
Core Principle: Every visible visual element in Matplotlib inherits from the
Artistbase class, exposing direct programmatic inspection and modification through getter and setter interfaces.
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
Exercise
Write artist_report(series), where series is a list of (label, values)
pairs, one per input line. Plot each series against its index, unpacking the
single Line2D that ax.plot returns inside a list, and set its line width to
1.5 through the Artist rather than through the plotting call. Find the series
with the highest final value, raise its zorder to 10, recolour it, and mark
its last point with ax.scatter. Build a legend only when
ax.get_legend_handles_labels() actually returns handles; a label beginning
with an underscore is deliberately hidden, and calling ax.legend() with
nothing to show produces an empty box.
Return three lines: lines=<n> collections=<n> legend=<n>, counting the Axes'
Line2D artists, its collections and the entries in the legend, which is zero
when ax.get_legend() is None; labels= followed by the legend-visible labels
in draw order and comma separated, or none; and top=<label>, the promoted
series, or none when there is no input. A file in which every label starts
with an underscore must still report its lines and collections.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | control 1.0 2.0 3.0 treated 1.0 4.0 2.5 drug 0.5 0.5 6.0 | lines=3 collections=1 legend=3 labels=control,treated,drug top=drug |
| 2 | stdin | _hidden 1.0 2.0 _also 3.0 1.0 | lines=2 collections=1 legend=0 labels=none top=_hidden |
| 3 | stdin | TP53 1.0 9.0 _background 0.1 0.2 BRCA1 2.0 3.0 | lines=3 collections=1 legend=2 labels=TP53,BRCA1 top=TP53 |
| 4 | stdin | solo 4.0 4.0 4.0 | lines=1 collections=1 legend=1 labels=solo top=solo |
Hints · 3
01Hint
`line, = ax.plot(...)` unpacks the one-element list `plot` returns.
02Hint
`ax.scatter` adds a single PathCollection for all its points, not one Artist per point.
03Hint
`ax.get_legend()` is None until a legend has actually been created.