TL;DR: This page describes an approach to composing figures in Pythonâs matplotlib. The writing is messy, but I think the main idea is clear enough to be useful.
Composing Figures in matplotlib can be challenging.
Options include
- subplots(Axes within a Figure, created from methods on Figure instances), and
- gridspecs(also subplots within a figure, created using a more expressive gridspec syntax).
These are ok, but there are some aspects of these solutions that I donât particularly like.
Research Figures often need:
- Complex layouts - not always needed but nice to know itâs possible.
- Modularity - figures standalone (ie for a presentation slide) and composed (ie for a manuscript).
- Often we want to maintain standalone Figure A, standalone Figure B, and a composed Figure A+B.
 
- Provenance & editability - the method described here doesnât solve for this, but lays a foundation.
Existing Approaches for Composing Figures:
- If we go for composition of Axes within a Figure, I have never understood how to untangle one Axis from the rest of the Figure. It would be great to be able to do this so we a can access Figure API stuff, like savefig, to do things to just Figure A.
- The other choice is standalone Figures. It wasnât clear to me how to compose these. The naive solution would be to collect a bunch of Figure objects then somehow stitch them together. Something like
figure_a = make_figure_a(data)
figure_b = make_figure_b(data)
 
plt.combine_figs([figure_a, figure_b])I havenât found a straightforward way to do this. Others have asked:
https://stackoverflow.com/questions/45810557/copy-an-axes-content-and-show-it-in-a-new-figure
Solutions in the stackoverflow post use pickle
...
inx = list(fig.axes).index(event.inaxes)
buf = io.BytesIO()
pickle.dump(fig, buf)
buf.seek(0)
fig2 = pickle.load(buf) 
for i, ax in enumerate(fig2.axes):
    if i != inx:
        fig2.delaxes(ax)
    else:
        axes=ax
axes.change_geometry(1,1,1)
fig2.show()
...
, or pull data out of the Figure object:
fig, axs = plot_something()
ax = axs[2]
l = list(ax.get_lines())[0]
l2 = list(ax.get_lines())[1]
l3 = list(ax.get_lines())[2]
plot(l.get_data()[0], l.get_data()[1])
plot(l2.get_data()[0], l2.get_data()[1])
plot(l3.get_data()[0], l3.get_data()[1])
ylim(0,1)These solutions donât feel very direct or readable.
- I donât find the gridspec syntax very readable.
This is subjective, but here is an example of some gridspec code from the matplotlib docs:
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
def format_axes(fig):
    for i, ax in enumerate(fig.axes):
        ax.text(0.5, 0.5, "ax%d" % (i+1), va="center", ha="center")
        ax.tick_params(labelbottom=False, labelleft=False)
# gridspec inside gridspec
fig = plt.figure()
gs0 = gridspec.GridSpec(1, 2, figure=fig)
gs00 = gridspec.GridSpecFromSubplotSpec(3, 3, subplot_spec=gs0[0])
ax1 = fig.add_subplot(gs00[:-1, :])
ax2 = fig.add_subplot(gs00[-1, :-1])
ax3 = fig.add_subplot(gs00[-1, -1])
# the following syntax does the same as the GridSpecFromSubplotSpec call above:
gs01 = gs0[1].subgridspec(3, 3)
ax4 = fig.add_subplot(gs01[:, :-1])
ax5 = fig.add_subplot(gs01[:-1, -1])
ax6 = fig.add_subplot(gs01[-1, -1])
plt.suptitle("GridSpec Inside GridSpec")
format_axes(fig)
plt.show()
A Different Approach
I want to highlight a less-emphasized way of composing figures in matplotlib. This isnât new, but I donât see it used or discussed often.
To do this, weâll use some techniques from functional programming.
One of them is partial function application. Another is first-class functions.
This is also a chance to use some less-known features of Python typing, like Protocol.
Part 1: Background
Letâs read the docs here:
https://matplotlib.org/stable/gallery/subplots_axes_and_figures/subfigures.html
We can see there is a way to nest visualizations at the Figure level, instead of the Axes level.
https://matplotlib.org/stable/_images/sphx_glr_subfigures_004_2_00x.png
Part 2: Strategy
Instead of trying to compose figure objects, weâll compose the functions that create them.
Weâll define functions that accept a Figure (expected to be empty), some data, and other keyword arguments, and return a Figure.
How to compose them: We need a function to orchestrate the composition of the other functions.
Letâs call this compose_on_subfigures.
This is where first-class functions come in â weâll pass this function a list of the visualizer functions from before.
compose_on_subfigures looks like this:
It takes a Figure, some data, some keyword arguments that dictate how to split up fig into SubFigures, and a list of subfigure_fns.
Each of the subfigure_fns is a Callable that should accept two arguments, fig and data, plus arbitrary keyword arguments, and return a Figure.
def compose_on_subfigures(
    fig: Figure,
    data: pd.DataFrame,
    subfigure_kwargs: dict,
    subfigure_fns: List[Callable],
) -> Figure:
    """
    This function creates a figure with multiple subfigures.
 
    Parameters:
    data: The data to be plotted.
    subfigure_kwargs (dict): The keyword arguments to be passed to the subfigures.
    subfigure_fns (List[Callable]): A list of functions that generate each subfigure.
 
    Returns:
    Figure: The final figure with all subfigures.
    """
    subfigs = fig.subfigures(**subfigure_kwargs)
    for subfig, subfig_fn in zip(subfigs, subfigure_fns):  # type: ignore
        subfig_fn(data=data, fig=subfig)
 
    return figNotice anything? The function signature for compose_on_subfigures, and the function signature for a subfig_fn are almost the same! This allows us to nest calls to compose_on_subfigures to achieve any nested grid layout we want. And because each of the subfigure_fns return Figures (instead of Axes), we can also use this completely standalone.
More precise typing with Protocol
If we want to enhance the level of specificity of typing for the subfigure_fns argument, we have some options:
Callable
or
Callable[Figure,DataFrame] -> Figure
The latter is pretty good. Some downsides though:
- it indicates typing by position. Not necessarily a downside but not clear whether it will support calls like my_figure_fn(data,fig)andmy_figure_fn(fig,data)equally well. Havenât checked but it probably shouldnât.
Also, I donât think **kwargs is supported in the signature for Callable.
For more strict typing, we can use Protocol from the typing module.
class FigureMaker(Protocol):
    def __call__(
        self,
        fig: Figure,
        data: pd.DataFrame
        **kwargs,
    ) -> Figure: ...This type annotation can be used like:
def compose_on_subfigures(
    fig: Figure,
    data: pd.DataFrame,
    subfigure_kwargs: dict,
    subfigure_fns: List[FigureMaker],
) -> Figure:
    # ... same as above
    return fig