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

Plugin unload() teardown sequence Flowchart showing the five ordered steps inside unload(): stop background threads, disconnect signals, deregister UI and map tools, close database and file handles, then clear Python references to None. unload() called QGIS shutdown sequence 1 — Stop background threads thread.quit() → thread.wait(3000) 2 — Disconnect Qt signals try: signal.disconnect(slot) / except: pass 3 — Deregister UI and map tools unsetMapTool() · removeToolBarIcon() 4 — Close database and file handles conn.close() · os.remove(temp_path) 5 — Clear Python references → None

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.

python
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:

python
# __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)
python
# 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 None in __init__, not in initGui. This guarantees that unload() can safely perform if self.x is not None checks even when initGui was never called (e.g. during unit tests or headless execution).
  • Track all connections in a list — e.g. self._connections: list[tuple] = [] — appended in initGui and iterated in unload(). This prevents silent drift between connected and disconnected slots.
  • Use QgsMessageLog only 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 in unload(). Setting project properties or removing layers during shutdown can trigger cascading projectChanged signals 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 QgsProcessingAlgorithm providers, call QgsApplication.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 a try/except.

Pre-Deployment Cleanup Checklist

  • Every signal.connect(slot) in initGui() has a corresponding signal.disconnect(slot) attempt in unload()
  • All QThread instances call quit() followed by wait(timeout_ms)
  • iface.removeToolBarIcon() is called for every action added via iface.addToolBarIcon()
  • canvas.unsetMapTool() is guarded by an identity check against canvas.mapTool()
  • Every file and database handle is explicitly closed
  • All self._temp_files entries (including -wal and -shm variants) are removed
  • unload() is wrapped in a top-level try/except to 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