How to Create 3D Subplots in Matplotlib (Python)

To create 3D subplots in Matplotlib, pass projection="3d" to the subplot you want in 3D: fig.add_subplot(1, 2, 2, projection="3d"), or make every subplot 3D with plt.subplots(2, 2, subplot_kw={"projection": "3d"}). You can mix 2D and 3D subplots in one figure and use any 3D plot type (surface, wireframe, scatter, line) in each. This guide shows each approach with real Matplotlib windows, including 3D scatter subplots, per-panel projections with subplot_mosaic and fixing cut-off labels.

Tested with Python 3.12.5, Matplotlib 3.11.2 and NumPy 2.5.3; the plots are real Matplotlib windows. Reference: 3D plots as subplots and the mplot3d toolkit.

Mix 2D and 3D subplots with add_subplot()

import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(-3, 3, 60)
X, Y = np.meshgrid(x, x)
Z = np.exp(-(X ** 2 + Y ** 2) / 2)

fig = plt.figure(figsize=(10, 4.5))
ax1 = fig.add_subplot(1, 2, 1)                       # a normal 2D subplot
ax1.contourf(X, Y, Z, levels=15, cmap="viridis")
ax1.set_title("2D contour")

ax2 = fig.add_subplot(1, 2, 2, projection="3d")      # a 3D subplot next to it
ax2.plot_surface(X, Y, Z, cmap="viridis")
ax2.set_title("3D surface")
plt.tight_layout()
plt.show()
Matplotlib figure with a 2D contour plot on the left and a 3D surface subplot on the right created with add_subplot projection 3d
A 2D subplot and a 3D subplot in one figure.

fig.add_subplot(111, projection="3d") is the same idea for a figure with a single 3D plot (1 row, 1 column, first subplot).

A grid of 3D subplots with plt.subplots()

subplot_kw passes its keywords to every subplot, so all of them become 3D axes:

import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(-3, 3, 40)
X, Y = np.meshgrid(x, x)
rng = np.random.default_rng(3)

fig, axes = plt.subplots(2, 2, figsize=(8, 6), subplot_kw={"projection": "3d"})   # every subplot is 3D
axes[0, 0].plot_surface(X, Y, np.sin(np.hypot(X, Y)), cmap="coolwarm")
axes[0, 0].set_title("plot_surface")
axes[0, 1].plot_wireframe(X, Y, X * np.exp(-X ** 2 - Y ** 2), rstride=3, cstride=3)
axes[0, 1].set_title("plot_wireframe")
pts = rng.normal(size=(200, 3))
axes[1, 0].scatter(pts[:, 0], pts[:, 1], pts[:, 2], c=pts[:, 2], cmap="plasma", s=12)
axes[1, 0].set_title("scatter")
t = np.linspace(0, 6 * np.pi, 300)
axes[1, 1].plot(np.cos(t), np.sin(t), t, color="tab:green")
axes[1, 1].set_title("3D line (helix)")
plt.tight_layout()
plt.show()
Matplotlib 2 by 2 grid of 3D subplots showing a surface, a wireframe, a 3D scatter plot and a helix
Four 3D plot types in a 2×2 grid.

3D scatter plots in subplots

A common use of 3D subplots is to show the same point cloud from several angles, since readers of a static image cannot rotate it:

import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(8)
groups = {"A": rng.normal(0, 1, (80, 3)), "B": rng.normal(3, 1, (80, 3))}

fig, axes = plt.subplots(1, 3, figsize=(12, 4.2), subplot_kw={"projection": "3d"})
for ax, (elev, azim) in zip(axes, [(20, -60), (20, 30), (80, -90)]):
    for name, pts in groups.items():
        ax.scatter(pts[:, 0], pts[:, 1], pts[:, 2], s=10, label=name)
    ax.view_init(elev=elev, azim=azim)              # same data, three camera angles
    ax.set_title(f"elev={elev}, azim={azim}")
axes[0].legend()
plt.tight_layout()
plt.show()
Three Matplotlib 3D scatter subplots of two groups of points shown from three different view angles with view_init
The same two groups from three camera angles.

For more on 3D scatter plots (colours, sizes, saving and rotating), see how to create a 3D scatter plot in Matplotlib.

Only some panels in 3D: subplot_mosaic

In Matplotlib 3.7 and later, subplot_mosaic() accepts per_subplot_kw, so you can make just one named panel 3D in a complex layout:

import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 10, 200)
fig, axd = plt.subplot_mosaic([["surface", "line"], ["surface", "bars"]], figsize=(10, 6),
                              per_subplot_kw={"surface": {"projection": "3d"}})   # only one panel is 3D

X, Y = np.meshgrid(np.linspace(-2, 2, 40), np.linspace(-2, 2, 40))
axd["surface"].plot_surface(X, Y, X * Y, cmap="viridis")
axd["line"].plot(x, np.sin(x))
axd["bars"].bar(["A", "B", "C"], [3, 5, 2])
for name, ax in axd.items():
    ax.set_title(name)
plt.tight_layout()
plt.show()
Matplotlib subplot_mosaic layout with a large 3D surface panel on the left and 2D line and bar panels on the right
One 3D panel and two 2D panels.

Do you need to import Axes3D?

No. Since Matplotlib 3.2 the 3D projection is registered automatically; from mpl_toolkits.mplot3d import Axes3D is only needed in very old versions. The object you get back is a 3D axes with extra methods such as set_zlabel():

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

fig = plt.figure()
ax2d = fig.add_subplot(1, 2, 1)
ax3d = fig.add_subplot(1, 2, 2, projection="3d")     # no "from mpl_toolkits.mplot3d import Axes3D" needed

print("2D axes:", type(ax2d).__name__)
print("3D axes:", type(ax3d).__name__, "from", type(ax3d).__module__)
print("has set_zlabel?", hasattr(ax2d, "set_zlabel"), "/", hasattr(ax3d, "set_zlabel"))

Output:

2D axes: Axes
3D axes: Axes3D from mpl_toolkits.mplot3d.axes3d
has set_zlabel? False / True
Command Prompt output showing that add_subplot with projection 3d returns an Axes3D object without importing Axes3D
projection="3d" returns an Axes3D.

Fix cut-off labels in 3D subplots

3D axes fill their whole subplot area, so in narrow or crowded figures the z-axis label is easily clipped at the edge. tight_layout() alone does not always help; shrinking the 3D box with set_box_aspect(None, zoom=...) (Matplotlib 3.6+) leaves more margin:

import matplotlib.pyplot as plt
import numpy as np

X, Y = np.meshgrid(np.linspace(-3, 3, 40), np.linspace(-3, 3, 40))
Z = np.cos(X) * np.sin(Y)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4.5), subplot_kw={"projection": "3d"})
for ax in (ax1, ax2):
    ax.plot_surface(X, Y, Z, cmap="terrain")
    ax.set_xlabel("X axis")
    ax.set_ylabel("Y axis")
    ax.set_zlabel("Z value")
ax1.set_title("default box size")
ax2.set_box_aspect(None, zoom=0.85)                  # shrink the box so labels fit
ax2.set_title("set_box_aspect(None, zoom=0.85)")
plt.tight_layout()
plt.show()
Two Matplotlib 3D surface subplots, the right one drawn smaller with set_box_aspect zoom to leave more room around the axis labels
Right: zoom=0.85 draws the 3D box smaller, leaving more margin for the labels.

You can also give the figure more width, or increase ax.zaxis.labelpad.

More Matplotlib 3D and subplot tutorials:

Frequently asked questions

How do I create 3D subplots in Matplotlib?

Use fig.add_subplot(rows, cols, index, projection="3d") for each 3D subplot, or plt.subplots(rows, cols, subplot_kw={"projection": "3d"}) to make them all 3D.

What does fig.add_subplot(111, projection=’3d’) mean?

It adds a single subplot (1 row, 1 column, position 1) that uses the 3D projection, so you can call 3D methods such as plot_surface() and scatter(x, y, z) on it.

Can I mix 2D and 3D subplots in one figure?

Yes. Create the 2D subplots normally and pass projection="3d" only to the ones that should be 3D, or use subplot_mosaic(per_subplot_kw=...).

Do I need to import Axes3D for 3D plots?

Not in Matplotlib 3.2 or later; projection="3d" works without the import.

How do I plot 3D scatter plots in subplots?

Create 3D subplots with subplot_kw={"projection": "3d"} and call ax.scatter(x, y, z) on each; use view_init() to show different angles.

Why is the z-axis label cut off in my 3D subplot?

The 3D box fills the axes area. Use ax.set_box_aspect(None, zoom=0.85), a wider figure, or a larger ax.zaxis.labelpad.

Leave a Comment