Properly Cleaning Up Plugin Resources on QGIS Shutdown
Implement a deterministic unload() method that stops background threads, disconnects Qt signals, deregisters map tools, closes database handles, and clears…
To properly clean up plugin resources on QGIS shutdown, implement a deterministic unload() method that explicitly reverses every initialization step: stop background threads, disconnect all Qt signals, deregister custom map tools, remove UI elements, and close file and database handles. QGIS automatically invokes unload() during application exit, but Python’s garbage collector will not reliably release C+±backed QGIS objects, locked files, or active thread pools — so manual teardown is mandatory.
This page is part of the Plugin Lifecycle and Resource Management guide within Plugin Development & UI Integration.
Why Explicit Teardown Is Non-Negotiable
When QGIS begins its shutdown sequence, it iterates through the plugin registry and calls unload() on each active module. If your plugin registers a QgsMapTool, opens a persistent sqlite3 connection, or starts a QThread for heavy geoprocessing, those resources persist in memory or on disk until explicitly released.
Python’s reference counting and cyclic garbage collector are unaware of QGIS’s underlying C++ object graph. Relying on __del__ or implicit scope exit frequently leaves dangling pointers, orphaned toolbar actions, and locked SQLite WAL files. The cleanup order matters and must be followed consistently: terminate background workers first, then disconnect signals, tear down UI components, close I/O handles, and finally null out Python references.
The signal and slot event handling system in PyQGIS is one of the most common sources of shutdown crashes — every connect() that is not paired with a matching disconnect() can trigger callbacks against partially destroyed objects.
Shutdown Teardown Order
Production-Ready unload() Template
The following implementation covers the most common resource leak vectors in QGIS 3.x plugins. It includes defensive error handling, explicit thread termination, and safe UI deregistration. Every attribute initialised in __init__ or initGui has a corresponding cleanup path.
from __future__ import annotations
from qgis.core import QgsProject, QgsMessageLog, Qgis
from qgis.gui import QgsMapTool
from qgis.PyQt.QtCore import QThread
from qgis.PyQt.QtWidgets import QAction
import sqlite3
import os
class MyPlugin:
"""QGIS plugin demonstrating complete resource lifecycle management."""
def __init__(self, iface) -> None:
self.iface = iface
self.actions: list[QAction] = []
self.map_tool: QgsMapTool | None = None
self.worker_thread: QThread | None = None
self.db_conn: sqlite3.Connection | None = None
self._temp_files: list[str] = []
def initGui(self) -> None:
"""Register all UI elements, connect signals, and start background workers."""
# Toolbar action
self.action = QAction("Run Process", self.iface.mainWindow())
self.iface.addToolBarIcon(self.action)
self.actions.append(self.action)
self.action.triggered.connect(self.run_process)
# Map tool — subclass QgsMapTool for production use
self.map_tool = QgsMapTool(self.iface.mapCanvas())
self.iface.mapCanvas().setMapTool(self.map_tool)
# Background thread for heavy geoprocessing
self.worker_thread = QThread()
self.worker_thread.start()
# SQLite cache — track path for cleanup
db_path = os.path.join(self._plugin_dir(), "plugin_cache.db")
self.db_conn = sqlite3.connect(db_path)
self._temp_files.append(db_path)
# Project-level signal
QgsProject.instance().layerWasAdded.connect(self._handle_layer)
def unload(self) -> None:
"""Release all plugin resources in safe teardown order.
This method is called by QGIS during both plugin deactivation and
application shutdown. It must be idempotent: repeated calls must not
raise exceptions or corrupt state.
"""
try:
# ── 1. Stop background threads ─────────────────────────────────
if self.worker_thread and self.worker_thread.isRunning():
self.worker_thread.quit()
# Block for up to 3 s; log and continue if the thread hangs
if not self.worker_thread.wait(3000):
QgsMessageLog.logMessage(
"Worker thread did not terminate within 3 s — "
"forcing continuation of shutdown.",
"MyPlugin",
Qgis.Critical,
)
self.worker_thread = None
# ── 2. Disconnect Qt signals ────────────────────────────────────
# Wrapping in try/except is mandatory: PyQt raises TypeError when
# the slot was never connected, and RuntimeError when the C++
# object has already been destroyed by QGIS.
for signal, slot in [
(QgsProject.instance().layerWasAdded, self._handle_layer),
]:
try:
signal.disconnect(slot)
except (TypeError, RuntimeError):
pass # Acceptable: already disconnected or never connected
# Disconnect action signals before removing icons
for action in self.actions:
try:
action.triggered.disconnect(self.run_process)
except (TypeError, RuntimeError):
pass
# ── 3. Deregister map tool and UI elements ──────────────────────
if self.map_tool is not None:
canvas = self.iface.mapCanvas()
# Only unset if this plugin's tool is currently active;
# unsetting someone else's tool would break other plugins.
if canvas.mapTool() is self.map_tool:
canvas.unsetMapTool(self.map_tool)
self.map_tool = None
for action in self.actions:
self.iface.removeToolBarIcon(action)
self.actions.clear()
# ── 4. Close database and file handles ──────────────────────────
if self.db_conn is not None:
try:
self.db_conn.close()
except sqlite3.Error as exc:
QgsMessageLog.logMessage(
f"DB close error: {exc}", "MyPlugin", Qgis.Warning
)
self.db_conn = None
# Remove temporary files (WAL/SHM artefacts etc.)
for fpath in list(self._temp_files):
for suffix in ("", "-wal", "-shm"):
target = fpath + suffix
if os.path.exists(target):
try:
os.remove(target)
except OSError as exc:
QgsMessageLog.logMessage(
f"Could not remove {target}: {exc}",
"MyPlugin",
Qgis.Warning,
)
self._temp_files.clear()
QgsMessageLog.logMessage(
"Plugin resources released cleanly.", "MyPlugin", Qgis.Info
)
except Exception as exc: # noqa: BLE001
QgsMessageLog.logMessage(
f"Critical error during unload: {exc}", "MyPlugin", Qgis.Critical
)
# ── Helpers ────────────────────────────────────────────────────────────
def run_process(self) -> None:
"""Triggered by toolbar action — implement plugin logic here."""
def _handle_layer(self, layer) -> None:
"""Slot connected to QgsProject.layerWasAdded."""
def _plugin_dir(self) -> str:
"""Return the plugin's own directory for relative path construction."""
import pathlib
return str(pathlib.Path(__file__).parent)
Architecture Breakdown
Thread Termination Contract
QThread.quit() posts a QEvent that exits the thread’s event loop; the thread itself does not stop until its run() method returns. wait(timeout_ms) then blocks the calling thread (the QGIS main thread) until termination or timeout. Never call terminate() — it forcibly kills the OS thread, leaving mutexes locked, shared memory corrupted, and any active QgsVectorDataProvider writes in an undefined state.
If your thread runs long-lived work rather than an event loop, emit a cancellation signal before calling quit() so the work loop exits voluntarily. The asynchronous task execution with QgsTask pattern is a higher-level alternative that integrates with QGIS’s own task manager and cancellation API, avoiding the need to manage QThread teardown manually.
Signal Disconnection Gotchas
PyQt raises TypeError when disconnect() is called on a signal that has no connections to the specified slot, and RuntimeError when the underlying C++ receiver has been garbage-collected before the Python disconnect() call arrives. Both are normal at shutdown — silence them with except (TypeError, RuntimeError): pass. Do not use a bare except Exception here, as that would also swallow genuine programming errors during development.
Iterating over a lookup table of (signal, slot) pairs, as shown in the template, keeps the unload() body readable and makes it easy to audit that every initGui connection has a corresponding entry. This pattern integrates cleanly with the signal and slot event handling patterns covered elsewhere on this site.
Map Tool Deregistration
canvas.mapTool() is self.map_tool uses identity comparison, not equality. This is intentional: a different plugin may have activated its own map tool since yours was set, and unsetting a foreign tool at shutdown would break that plugin’s session. Always check identity before calling unsetMapTool().
If your plugin activates a map tool in response to a button click rather than at startup, track whether the tool is currently active with an instance flag rather than querying the canvas on every unload.
Database Handle Closure and SQLite WAL Files
SQLite in WAL mode (default for concurrent access) writes two auxiliary files: database.db-wal and database.db-shm. If the main connection closes normally, SQLite checkpoints the WAL back into the main database file and removes both auxiliaries. If Python’s GC closes the connection non-deterministically — which happens when QGIS exits without an explicit conn.close() — the WAL may be left on disk. On the next startup, SQLite replays the WAL during connect(), which is usually safe but adds latency and can surface as apparent data corruption if the WAL was written by an older schema version. Explicit closure in unload() eliminates this entirely.
The same principle applies to any other file-backed resource: csv.writer buffers, Fiona dataset handles, or rasterio file objects opened for raster data access patterns.
Reference Clearing
Setting self.worker_thread = None and self.map_tool = None after cleanup does two things. First, it prevents unload() from acting on already-released resources if called a second time (the QGIS Plugin Manager can call unload() twice during reload cycles). Second, it breaks circular references between the plugin object, its Qt children, and any closures that captured self — allowing CPython’s reference counter to immediately reclaim the objects rather than waiting for a cyclic GC cycle.
Wiring unload() Into the Plugin Entry Point
Every QGIS plugin exposes a module-level classFactory function that the plugin loader calls on startup. The returned object’s unload() is the method QGIS will invoke at shutdown. Nothing extra is needed beyond implementing the method, but confirming the structure is correct prevents hard-to-diagnose “plugin not unloading” reports:
# __init__.py — plugin package entry point
def classFactory(iface):
"""Required entry point called by the QGIS plugin loader."""
from .plugin import MyPlugin
return MyPlugin(iface)
# plugin.py — main plugin class (abbreviated)
class MyPlugin:
def __init__(self, iface): ...
def initGui(self): ...
def unload(self): ... # QGIS calls this on deactivation and shutdown
QGIS calls unload() in two distinct scenarios: when the user disables the plugin via the Plugin Manager (while the application keeps running), and during application exit. The method must handle both cleanly. Querying self.iface.mapCanvas() is safe in the first scenario but may raise a RuntimeError if the canvas C++ object has already been destroyed in the second. Guard against this by wrapping the canvas interaction in a try/except RuntimeError block, or by checking QgsApplication.instance() before accessing GUI components.
Production Best Practices
- Initialise every resource attribute to
Nonein__init__, not ininitGui. This guarantees thatunload()can safely performif self.x is not Nonechecks even wheninitGuiwas never called (e.g. during unit tests or headless execution). - Track all connections in a list — e.g.
self._connections: list[tuple] = []— appended ininitGuiand iterated inunload(). This prevents silent drift between connected and disconnected slots. - Use
QgsMessageLogonly for failures during teardown, not for routine status messages. Log calls during shutdown are safe but I/O-heavy logging can trigger race conditions with the log panel’s UI cleanup. - Test
unload()explicitly by toggling the plugin off and on in the Plugin Manager during a live QGIS session, not just by closing the application. Reload cycles surface double-unload bugs that application exit alone will not trigger. - Avoid
QgsProject.instance()mutations inunload(). Setting project properties or removing layers during shutdown can trigger cascadingprojectChangedsignals while QGIS is in the middle of its own teardown sequence, leading to crashes that are hard to attribute to the plugin. - For plugins that register custom
QgsProcessingAlgorithmproviders, callQgsApplication.processingRegistry().removeProvider(self.provider)before clearing UI elements. The Processing Framework’s cleanup runs before plugin unloading in some QGIS versions, so guard this call with atry/except.
Pre-Deployment Cleanup Checklist
- Every
signal.connect(slot)ininitGui()has a correspondingsignal.disconnect(slot)attempt inunload() - All
QThreadinstances callquit()followed bywait(timeout_ms) iface.removeToolBarIcon()is called for every action added viaiface.addToolBarIcon()canvas.unsetMapTool()is guarded by an identity check againstcanvas.mapTool()- Every file and database handle is explicitly closed
- All
self._temp_filesentries (including-waland-shmvariants) are removed unload()is wrapped in a top-leveltry/exceptto prevent shutdown crashes- No module-level global variables hold references to QGIS objects across
unload()calls unload()has been tested via Plugin Manager reload, not only via application exit
Related
- Plugin Lifecycle and Resource Management — parent guide covering the full four-phase plugin lifecycle
- Asynchronous Task Execution with QgsTask — higher-level alternative to raw
QThreadfor background work - Running Heavy Geoprocessing in the Background Without Freezing the UI — practical patterns for worker cancellation and progress reporting
- Signal and Slot Event Handling in QGIS — covers the PyQt signal dispatch model underpinning safe
disconnect()patterns - Memory Management and Garbage Collection for GIS Objects — Python/C++ SIP ownership rules that explain why
unload()must be explicit