Writing an Idempotent Plugin unload() Method
Write a QGIS plugin teardown that can run twice: attributes initialised in __init__, recorded signal connections, the order to undo registrations, swallowing…
TL;DR: initialise every resource attribute to None in __init__, track each connection and registration in a list as you create it, and write unload() so that running it twice — or after a failed initGui() — is harmless. This page is part of the plugin lifecycle and resource management guide.
Complete Runnable Code
"""A plugin whose teardown is safe to run twice, or after a failed start-up."""
from qgis.PyQt.QtWidgets import QAction
class MyPlugin:
"""Every resource is declared in __init__ so unload() can always run."""
def __init__(self, iface):
self.iface = iface
self.actions: list[QAction] = []
self.connections: list[tuple] = [] # (signal, slot) pairs we made
self.dock = None
self.options_factory = None
self.map_tool = None
# ---- start-up ------------------------------------------------------
def initGui(self) -> None:
action = QAction("Do the thing", self.iface.mainWindow())
action.triggered.connect(self.run)
self.iface.addToolBarIcon(action)
self.iface.addPluginToMenu("&My Plugin", action)
self.actions.append(action)
project = self.iface.mapCanvas()
self._connect(project.extentsChanged, self.on_extent_changed)
def _connect(self, signal, slot) -> None:
"""Connect and record, so unload() has an exact list to undo."""
signal.connect(slot)
self.connections.append((signal, slot))
# ---- teardown ------------------------------------------------------
def unload(self) -> None:
"""Undo initGui(). Safe to call twice, and after a failed initGui()."""
# 1. signals first, so nothing fires into a half-dismantled plugin
for signal, slot in self.connections:
try:
signal.disconnect(slot)
except (TypeError, RuntimeError):
pass # already gone; nothing to undo
self.connections.clear()
# 2. interface elements
for action in self.actions:
self.iface.removePluginMenu("&My Plugin", action)
self.iface.removeToolBarIcon(action)
self.actions.clear()
if self.options_factory is not None:
self.iface.unregisterOptionsWidgetFactory(self.options_factory)
self.options_factory = None
# 3. canvas state
if self.map_tool is not None:
canvas = self.iface.mapCanvas()
if canvas.mapTool() is self.map_tool:
canvas.unsetMapTool(self.map_tool)
self.map_tool = None
# 4. widgets we own
if self.dock is not None:
self.iface.removeDockWidget(self.dock)
self.dock.deleteLater()
self.dock = None
Architecture Breakdown
Declaring attributes in __init__ is what makes it safe
unload() can be called when initGui() never ran to completion — an exception halfway through leaves some resources created and others not. If the attributes only come into existence inside initGui(), the teardown raises AttributeError on the first one that is missing, and everything after it is skipped.
Setting each to None (or an empty list) in __init__ means every branch in unload() has something to test, and the whole method runs regardless of how far start-up got.
Recording connections beats remembering them
A plugin that connects to half a dozen signals across several methods will eventually forget one, and a forgotten connection is a slot firing into a dead object. Recording each (signal, slot) pair as it is made turns disconnection from an act of memory into a loop.
The try around disconnect is not laziness. Qt raises TypeError when a connection has already been removed and RuntimeError when the emitting object is gone, and both are perfectly normal during shutdown.
unload() is called in three quite different situations
The method has one signature and three callers, and each caller leaves the process in a different state by the time it runs.
The reload case is the one worth designing for, because it happens most often and because initGui() runs immediately afterwards. A teardown that leaves one action behind produces a visible duplicate on the second reload, and a plugin developer sees it within minutes — which is exactly why reload-driven development finds these bugs so effectively.
The shutdown case is the one that fails most obscurely: other objects may already be partly destroyed, so anything your teardown touches must be guarded rather than assumed.
Diagnosing a Teardown That Fails on the Second Run
A teardown that works once and raises the second time is almost always one of three mistakes, and the exception type names which.
Three exception types account for nearly every case. AttributeError means an attribute was created in initGui() rather than __init__. RuntimeError: wrapped C/C++ object has been deleted means the C++ side went first and the Python wrapper outlived it — guard with sip.isdeleted() before touching anything long-lived. TypeError from disconnect means the connection was already removed, which is benign and should simply be swallowed.
Testing It
The cheapest test is the one a developer runs by accident: toggle the plugin off and on in the Plugin Manager twenty times in a live session. A correct teardown leaves the toolbar with the same number of buttons it started with and the Python error log empty.
For something automated, calling unload() twice in a row inside a pytest-qgis test catches the idempotence failures without a GUI:
def test_unload_is_idempotent(qgis_iface):
plugin = MyPlugin(qgis_iface)
plugin.initGui()
plugin.unload()
plugin.unload() # must not raise
assert plugin.actions == []
Why Idempotence Is the Right Property to Aim For
It would be possible to make teardown correct by guaranteeing it runs exactly once, and some plugins try — a flag that says “already unloaded”, checked at the top. That approach solves the double-call case and none of the others.
The situations that actually occur are messier than “twice”. A failed initGui() means teardown runs against a half-built plugin. A reload during development means teardown runs immediately before a fresh start-up. A crash in one part of unload() means the rest may run later, or not at all. Idempotence — every step safe to run at any time, in any state — covers all of them without enumerating them.
That is why the shape of the code matters more than the individual calls. Attributes that always exist, lists that are cleared as they are undone, and guards that test rather than assume produce a teardown you do not have to reason about case by case.
Production Best Practices
- Initialise every resource attribute to
Nonein__init__. - Record connections and registrations in lists as you create them.
- Disconnect before removing, so nothing fires into a partly dismantled plugin.
- Swallow
TypeErrorandRuntimeErrorfromdisconnect. Both are normal. - Clear the lists after undoing them, so a second run has nothing to do.
- Reload the plugin twenty times before releasing it; the failures show up quickly.
Frequently Asked Questions
Is unload() really called when QGIS closes?
Yes, for enabled plugins. Code that assumes otherwise leaks in the one case that is hardest to observe, because the process exits immediately afterwards and the leak never manifests visibly. It matters anyway: a file left open at shutdown can leave a lock behind on Windows.
Why does my toolbar have two buttons after a reload?
The previous unload() did not remove the action, so the second initGui() added another. Either the action was never added to the tracking list, or the teardown raised before reaching it — check the Python error log for an exception during unload, which QGIS reports but does not make loud.
Should unload() catch every exception?
Catch narrowly and continue, rather than catching everything and hiding it. A teardown that stops at its first error leaves the rest of the plugin registered, so continuing is right — but swallowing an unexpected exception silently means you never learn about it. Log what you catch, even if you carry on.
Does deleteLater() help here?
For widgets, yes: it schedules deletion once control returns to the event loop, which is safer than deleting an object that may still be on the call stack. For actions removed from menus and toolbars, dropping the Python reference is normally enough, since the removal already detached them.
What if a background task is still running?
Cancel it and, where the task touches plugin state, wait for it. A task that completes after unload() calls into an object that no longer exists. Requesting cancellation and disconnecting the completion signal is usually sufficient; blocking on the task is a last resort, because it freezes the shutdown.
How much of this applies to a Processing provider?
All of it, with one addition: the provider must be removed from the processing registry in unload(). Leaving it registered gives the user algorithms that fail on run, and reloading the plugin registers a second provider with the same id.
Related
- Plugin Lifecycle and Resource Management — the parent guide covering the four lifecycle phases
- Properly Cleaning Up Plugin Resources on QGIS Shutdown — the teardown order in more detail
- Building Exclusive Tool Modes with QActionGroup — an example of state that must be undone