Maintained layer, checked 2026-09-01. This edition follows the current Matplotlib Qt embedding API and targets PyQt5 explicitly. The complete 2019 export is preserved verbatim at the end. Its canvas-order and
mouse_init()suggestions were useful observations in an older stack, but they are not presented as universal fixes for current Matplotlib.
An interactive 3D plot needs more than a rendered image. A Qt canvas must receive input events, one owner must run the Qt event loop, redraws must return control to that loop, and the Python objects involved must remain alive. The example below separates plot construction from the PyQt5 widget so that numerical drawing can be tested headlessly without pretending that a non-interactive backend tests mouse behavior.
Table of Contents
What Changed Since the 2019 Note
Current Matplotlib uses the unified QtAgg backend and imports its canvas from matplotlib.backends.backend_qtagg. That backend can work with PyQt6, PySide6, PyQt5, or PySide2; importing PyQt5.QtCore first selects PyQt5. The older backend_qt5agg compatibility module remains available, but Matplotlib’s documentation discourages it for new code.
Modern 3D axes are created with figure.add_subplot(projection="3d"); a separate Axes3D import is not needed. An Axes3D already has mouse interaction under an interactive backend. mouse_init() configures the buttons used for rotation, panning, and zooming; it is not a general repair for a missing Qt event loop, a non-interactive backend, a destroyed canvas, or a blocked GUI thread.
The 2019 superclass example also uses super(FigureCanvas, self), which bypasses the intended FigureCanvas initializer. New subclasses should use super().__init__(Figure(...)), although composition—as used below—is usually simpler.
Architecture and Project Layout
Keep the reusable plot logic independent of PyQt5, then attach the same kind of Figure to different canvases at the boundary:
qt-3d-demo/
├── app.py
├── plot_model.py
└── test_plot_model.py
The GUI path is Figure → FigureCanvas → Qt layout. The headless path is Figure → FigureCanvasAgg → pixel render. Both paths exercise the same plot-building and update functions, but only the first owns an interactive Qt widget.
Install and Record a Known-Good Environment
Create and activate a virtual environment, then install the application and test dependencies:
python -m venv .venv
python -m pip install --upgrade pip
python -m pip install PyQt5 matplotlib numpy pytest
python -c "from PyQt5 import QtCore; import matplotlib, numpy; print('PyQt', QtCore.PYQT_VERSION_STR); print('Qt', QtCore.QT_VERSION_STR); print('Matplotlib', matplotlib.__version__); print('NumPy', numpy.__version__)"
The commands after venv assume that environment has been activated. PyQt5 and Matplotlib support depends on Python, operating system, architecture, package source, and the chosen release pair. Do not infer production compatibility from this unpinned installation command; once the application works, capture and continuously test a lock file or constrained dependency set.
Matplotlib’s current dependency page lists PyQt5 5.12 or newer as an option for Qt backends, but that lower bound does not promise that every old PyQt5 release supports every new Python or platform. Test the actual matrix that the application claims to support.
Build a Canvas-Independent 3D Plot
Create plot_model.py:
from __future__ import annotations
import numpy as np
from matplotlib.figure import Figure
def helix_coordinates(phase: float):
t = np.linspace(0.0, 4.0 * np.pi, 400)
x = np.cos(t + phase)
y = np.sin(t + phase)
z = t / (4.0 * np.pi)
return x, y, z
def populate_figure(figure: Figure):
axes = figure.add_subplot(projection="3d")
x, y, z = helix_coordinates(0.0)
(line,) = axes.plot(x, y, z, linewidth=2.0)
axes.set(
title="Interactive helix",
xlabel="x",
ylabel="y",
zlabel="z",
xlim=(-1.1, 1.1),
ylim=(-1.1, 1.1),
zlim=(0.0, 1.0),
)
return axes, line
def update_line(line, phase: float) -> None:
x, y, z = helix_coordinates(phase)
line.set_data_3d(x, y, z)
This module neither selects a GUI backend nor starts an event loop. It mutates an existing 3D line rather than clearing and recreating the axes on every update, preserving the current camera view and avoiding unnecessary artist allocation.
Embed the Figure with FigureCanvas and a Toolbar
Create app.py:
import sys
from PyQt5 import QtCore, QtWidgets
from matplotlib.backends.backend_qtagg import FigureCanvas
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT
from matplotlib.figure import Figure
from plot_model import populate_figure, update_line
class PlotWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("PyQt5 interactive 3D plot")
self.canvas = FigureCanvas(Figure(figsize=(7, 5)))
self.axes, self.line = populate_figure(self.canvas.figure)
self.toolbar = NavigationToolbar2QT(self.canvas, self)
self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
self.slider.setRange(0, 628)
self.slider.valueChanged.connect(self.set_phase)
self.animate_button = QtWidgets.QPushButton("Start animation")
self.animate_button.clicked.connect(self.toggle_animation)
central = QtWidgets.QWidget(self)
layout = QtWidgets.QVBoxLayout(central)
layout.addWidget(self.toolbar)
layout.addWidget(self.canvas, 1)
layout.addWidget(self.slider)
layout.addWidget(self.animate_button)
self.setCentralWidget(central)
self.timer = QtCore.QTimer(self)
self.timer.setInterval(50)
self.timer.timeout.connect(self.advance)
self.click_cid = self.canvas.mpl_connect(
"button_press_event", self.report_click
)
def set_phase(self, value: int) -> None:
update_line(self.line, value / 100.0)
self.canvas.draw_idle()
def toggle_animation(self) -> None:
if self.timer.isActive():
self.timer.stop()
self.animate_button.setText("Start animation")
else:
self.timer.start()
self.animate_button.setText("Stop animation")
def advance(self) -> None:
value = (self.slider.value() + 2) % (self.slider.maximum() + 1)
self.slider.setValue(value)
def report_click(self, event) -> None:
if event.inaxes is self.axes:
self.statusBar().showMessage(
f"button={event.button}; x={event.xdata:.3f}; y={event.ydata:.3f}"
)
def closeEvent(self, event) -> None:
self.timer.stop()
self.canvas.mpl_disconnect(self.click_cid)
super().closeEvent(event)
def main() -> int:
if QtWidgets.QApplication.instance() is not None:
raise RuntimeError("The host application already owns the Qt event loop")
application = QtWidgets.QApplication(sys.argv)
window = PlotWindow()
window.resize(900, 700)
window.show()
return application.exec()
if __name__ == "__main__":
raise SystemExit(main())
The explicit PyQt5 import occurs before backend_qtagg, so Matplotlib selects PyQt5 even if another supported Qt binding is installed. The canvas, axes, line, toolbar, timer, and callback identifier are attributes so their lifetimes are clear.
Run app.py in a graphical desktop session. Drag with the left mouse button to rotate, the middle button to pan, and the right button vertically to zoom under Matplotlib’s documented default 3D bindings. The 2D toolbar pan/zoom modes are not the 3D camera controls.
Let the Host Own the Qt Event Loop
A standalone application creates one QApplication, shows its window, then calls application.exec(). Qt dispatches window-system and input events inside that main loop. Do not add pyplot.show(), pyplot.pause(), another QApplication, or a nested exec() to this embedded-widget design.
If a larger PyQt5 application, an IDE, or a test harness already owns the event loop, it should construct and retain PlotWindow itself and must not call this module’s main(). That is why main() fails explicitly when a QApplication already exists instead of quietly starting a second loop.
Long-running calculations in a button callback block both the canvas and the rest of the interface. Compute them outside the GUI thread where appropriate, then deliver results back through Qt signals and mutate Matplotlib artists on the GUI thread.
Update Artists and Request Redraws
For data-only changes, retain the artist, call its update method—set_data_3d here—and then call canvas.draw_idle(). Matplotlib coalesces repeated draw_idle() requests until control returns to the GUI event loop. Use synchronous canvas.draw() only when the caller truly needs the completed renderer immediately.
If plot topology changes, rebuilding can be reasonable:
self.canvas.figure.clear()
self.axes, self.line = populate_figure(self.canvas.figure)
self.canvas.draw_idle()
Clearing on every animation frame is usually more expensive and discards the current 3D camera. Do not call flush_events() or QApplication.processEvents() in a tight loop as a substitute for an event-driven design.
Use Timers Without Starving the Interface
The example’s QTimer changes the slider at 20 frames per second; the slider signal performs one small artist update and schedules a redraw. The timer is parented to and retained by the window, and it is stopped during cleanup. Timer callbacks should return quickly because they run through the Qt event loop.
Data acquisition and drawing do not need the same rate. A worker may produce data quickly while a GUI timer presents only the newest snapshot at a bounded rate. Protect that handoff deliberately, avoid an unbounded queue, and never update Qt widgets directly from a worker thread.
Understand 3D Mouse Interaction
With an interactive canvas and a running event loop, Axes3D installs its normal mouse handling. Current mouse_init(rotate_btn=1, pan_btn=2, zoom_btn=3) configures which buttons control the camera. Call it only when the application intentionally needs different bindings or must undo a prior disable_mouse_rotation() call.
If interaction is absent, diagnose the foundation first: confirm that the visible widget really is the FigureCanvas holding the axes, the Qt event loop is running, the GUI thread is not blocked, the canvas object is still alive, and no overlay widget consumes its mouse events. Repeatedly calling mouse_init() cannot repair those conditions.
Clean Up Timers, Callbacks, and Widgets
Store every custom Matplotlib connection identifier returned by mpl_connect, then pass it to mpl_disconnect when the window closes or the associated controller is replaced. Stop active timers and cancel application-owned workers. Qt’s aboutToQuit signal is appropriate for application-wide resources that must be released even when code after exec() may not run on a platform.
This example uses Matplotlib’s object-oriented API and never registers the figure with pyplot, so there is no pyplot-managed window to close. The Qt parent/widget hierarchy owns the embedded canvas; explicit cleanup is still necessary for timers, callbacks, files, sockets, or workers owned by application code.
Select and Diagnose the Backend Deliberately
For new code, import FigureCanvas from backend_qtagg. Matplotlib chooses a binding from an already imported Qt binding, then QT_API, then its available-binding order. Importing PyQt5 first—as the example does—is the most local explicit choice. QT_API=PyQt5 is useful at process launch when one codebase supports several bindings.
Avoid setting MPLBACKEND globally. Matplotlib warns that a global override can produce counterintuitive behavior, and backend selection must happen before figures are created. An explicit canvas import is more direct for an embedded application than relying on matplotlib.get_backend() and pyplot auto-detection.
When a window does not appear, separate the layers:
- Run a minimal PyQt5 window to verify Qt and its platform plugin.
- Print
QtCore.PYQT_VERSION_STR,QtCore.QT_VERSION_STR, andmatplotlib.__version__from the same interpreter. - Confirm that the imported canvas module is
matplotlib.backends.backend_qtagg. - Run the application as a script outside an IDE to remove input-hook ambiguity.
- Reduce the plot to one line, then restore timers, callbacks, and workers one at a time.
Errors about xcb, windows, or cocoa platform plugins are Qt deployment problems, not failures of projection="3d" or reasons to call mouse_init().
Test the Headless Boundary Honestly
Create test_plot_model.py:
import numpy as np
from matplotlib.backends.backend_agg import FigureCanvasAgg
from matplotlib.figure import Figure
from plot_model import helix_coordinates, populate_figure, update_line
def test_headless_plot_can_update_and_render():
figure = Figure(figsize=(4, 3), dpi=100)
canvas = FigureCanvasAgg(figure)
axes, line = populate_figure(figure)
update_line(line, phase=0.5)
canvas.draw()
expected_x, expected_y, expected_z = helix_coordinates(0.5)
actual_x, actual_y, actual_z = line.get_data_3d()
np.testing.assert_allclose(actual_x, expected_x)
np.testing.assert_allclose(actual_y, expected_y)
np.testing.assert_allclose(actual_z, expected_z)
assert axes.figure is figure
assert canvas.get_width_height() == (400, 300)
Run it without a display server:
python -m pytest -q
This verifies plot construction, artist updates, and Agg rendering. It does not verify Qt plugin loading, window ownership, native input delivery, 3D dragging, high-DPI behavior, timer cadence, or cleanup. An offscreen Qt platform can add a widget-construction smoke test where supported, but retain at least one real GUI integration test for the interactive claims.
Version and Release Checklist
The maintained layer was checked against the then-current Matplotlib 3.11.1 documentation and archived Qt 5.15.19 documentation. Those documentation versions are evidence for this edition, not a promise that future defaults or an arbitrary older environment will be identical.
Before shipping:
- constrain and record Python, PyQt5, Qt, Matplotlib, and NumPy versions;
- test on every supported OS, architecture, display protocol, and scaling mode;
- verify PyQt5 licensing and the licenses of redistributed Qt components;
- run the Agg unit test plus a real Qt window/input integration test;
- test closing and reopening the view for timer, callback, and worker leaks;
- keep slow computation away from the GUI thread; and
- retest backend selection if another Qt binding is added to the environment.
If support for Qt 6 is desired, make it a separate tested migration. The unified backend_qtagg import helps, but enum names, Qt APIs, packaging, and application code can still differ between PyQt5 and PyQt6.
Primary Documentation
- Matplotlib: Embed in Qt
- Matplotlib: Qt backend and binding selection
- Matplotlib: backend selection and `QtAgg`
- Matplotlib: current dependencies for Qt backends
- Matplotlib: canvas redraws, callbacks, and timers
- Matplotlib: interactive figures and event loops
- Matplotlib: `mplot3d` behavior and mouse controls
- Matplotlib: `Axes3D.mouse_init`
- Qt 5.15: `QCoreApplication` event loop and cleanup
- Qt 5.15: `QTimer`
- PyQt5 official package metadata and installation
—
Original 2019 Export (Verbatim)
The following is the complete source export, including metadata, links, prose, and code. It was published on 2019-05-01 and last modified nine seconds later. The two Stack Overflow links and all historical API usage remain evidence of that note; they are not the authority for the maintained 2026 guidance. Nothing inside this archive has been corrected or modernized.
---
id: 1929
title: 'Embed an interactive 3D plot in PyQt5'
slug: 'embed-an-interactive-3d-plot-in-pyqt5'
date: '2019-05-01T13:42:15'
modified: '2019-05-01T13:42:24'
status: 'publish'
link: 'https://blog.lazying.art/en/html/computer_internet/pyqt/1929/embed-an-interactive-3d-plot-in-pyqt5.html'
author: 'Lachlan Chen'
categories:
- 'PyQt'
---
[https://stackoverflow.com/questions/18259350/embed-an-interactive-3d-plot-in-pyside/18278457#18278457%20…](https://stackoverflow.com/questions/18259350/embed-an-interactive-3d-plot-in-pyside/18278457#18278457%20...)
1) Create the FigureCanvas *before* adding the axes. See [https://stackoverflow.com/a/9007892/3962328](https://stackoverflow.com/a/9007892/3962328)
canvas = FigureCanvas(fig)
ax = figure.add_subplot(111, projection=’3d’)
or
class MyFigureCanvas(FigureCanvas):
def __init__(self):
self.figure = Figure()
super(FigureCanvas, self).__init__(self.figure)
self.axes = self.figure.add_subplot(111, projection=’3d’)
2) Try ax.mouse_init() to restore the connection:
…
ax = fig.gca(projection=”3d”)
…
canvas = FigureCanvas(fig)
ax.mouse_init()
