Properly Cleaning Up Plugin Resources on QGIS Shutdown

To properly clean up plugin resources on QGIS shutdown, implement a deterministic unload() method that explicitly reverses every initialization step:…

To properly clean up plugin resources on QGIS shutdown, implement a deterministic unload() method that explicitly reverses every initialization step: disconnect all Qt signals, terminate background threads, close file and database handles, deregister custom map tools, and remove UI elements. 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. Manual teardown is mandatory to prevent memory leaks, corrupted project states, and OS-level file locks.

Why Explicit Teardown Is Non-Negotiable

The Plugin Lifecycle and Resource Management workflow dictates that cleanup must be ordered and explicit. 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: stop background workers first, disconnect signals, tear down UI components, and finally clear class-level references.

flowchart TD
    A["unload() called"] --> B["1. Stop background threads: quit + wait"]
    B --> C["2. Disconnect signals in try/except"]
    C --> D["3. Deregister map tools and UI"]
    D --> E["4. Close DB and file handles"]
    E --> F["5. Clear Python references to None"]

Production-Ready unload() Template

The following implementation covers the most common leak vectors in QGIS 3.x plugins. It includes defensive error handling, explicit thread termination, and safe UI deregistration. For complete plugin scaffolding guidelines, refer to the official PyQGIS Developer Cookbook.

python
from qgis.core import QgsProject, QgsApplication, QgsMessageLog, Qgis
from qgis.gui import QgsMapTool
from PyQt5.QtCore import QThread
from PyQt5.QtWidgets import QAction
import sqlite3
import os

class MyPlugin:
    def __init__(self, iface):
        self.iface = iface
        self.actions = []
        self.map_tool = None
        self.worker_thread = None
        self.db_conn = None
        self._temp_files = []

    def initGui(self):
        # UI setup
        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 registration
        self.map_tool = QgsMapTool(self.iface.mapCanvas())
        self.iface.mapCanvas().setMapTool(self.map_tool)

        # Background thread
        self.worker_thread = QThread()
        self.worker_thread.start()

        # Database connection
        self.db_conn = sqlite3.connect("plugin_cache.db")

        # Track temp files for cleanup
        self._temp_files.append("plugin_cache.db")

    def unload(self):
        """Explicitly clean up all resources on QGIS shutdown."""
        try:
            # 1. Stop background threads gracefully
            if self.worker_thread and self.worker_thread.isRunning():
                self.worker_thread.quit()
                # Block until thread finishes (max 3s to avoid hanging shutdown)
                if not self.worker_thread.wait(3000):
                    QgsMessageLog.logMessage(
                        "Worker thread failed to terminate within timeout.", 
                        "MyPlugin", Qgis.Critical
                    )

            # 2. Disconnect signals
            try:
                QgsProject.instance().layerWasAdded.disconnect(self._handle_layer)
            except (TypeError, RuntimeError):
                pass  # Already disconnected or never connected

            # 3. Deregister map tool
            if self.map_tool:
                canvas = self.iface.mapCanvas()
                if canvas.mapTool() == self.map_tool:
                    canvas.unsetMapTool(self.map_tool)
                self.map_tool = None

            # 4. Remove UI elements
            for action in self.actions:
                self.iface.removeToolBarIcon(action)
                try:
                    action.triggered.disconnect(self.run_process)
                except (TypeError, RuntimeError):
                    pass
            self.actions.clear()

            # 5. Close database & file handles
            if self.db_conn:
                try:
                    self.db_conn.close()
                except sqlite3.Error:
                    pass
                self.db_conn = None

            # 6. Clean up tracked temporary files
            for fpath in self._temp_files:
                if os.path.exists(fpath):
                    try:
                        os.remove(fpath)
                    except OSError:
                        QgsMessageLog.logMessage(
                            f"Failed to remove temp file: {fpath}", 
                            "MyPlugin", Qgis.Warning
                        )
            self._temp_files.clear()

            QgsMessageLog.logMessage("Plugin resources successfully released.", "MyPlugin")
        except Exception as e:
            QgsMessageLog.logMessage(f"Critical unload error: {e}", "MyPlugin", Qgis.Critical)

    def run_process(self):
        pass  # Placeholder for plugin logic

    def _handle_layer(self, layer):
        pass  # Placeholder for signal handler

Execution Order Breakdown

  1. Thread Termination: Call quit() to exit the event loop, then wait(timeout) to block until the thread finishes. Never force-terminate threads; it corrupts shared memory and leaves mutexes locked. See Qt QThread Documentation for safe termination patterns.
  2. Signal Disconnection: Wrap disconnect() calls in try/except. If the slot was never connected or already removed, PyQt raises TypeError or RuntimeError. Silent failure here prevents cascade errors during shutdown.
  3. UI & Map Tool Deregistration: Always check canvas.mapTool() == self.map_tool before calling unsetMapTool(). Removing toolbar icons without clearing the actions list leaves dangling references that QGIS attempts to render during the final UI teardown.
  4. Database & File Handles: Close connections explicitly. SQLite leaves -wal and -shm files if connections aren’t cleanly closed, which can corrupt the database on next launch.
  5. Reference Clearing: Set instance attributes to None after cleanup. This breaks circular references and allows Python’s GC to sweep the plugin object immediately.

Common Pitfalls & Debugging

  • Hanging Shutdown: If unload() blocks indefinitely, QGIS will force-kill the process. Always use wait(timeout) for threads and avoid synchronous network calls during teardown.
  • Double Unload: Some QGIS versions call unload() twice during plugin manager reloads. Guard against None checks and use .clear() on lists to make the method idempotent.
  • Missing __init__ Cleanup: If your plugin modifies QgsProject properties or registers custom QgsProcessingAlgorithm providers, revert those changes in unload(). The Plugin Development & UI Integration guidelines emphasize that any global state mutation must be fully reversible.
  • Logging During Exit: QgsMessageLog remains functional during shutdown, but avoid heavy I/O. Use it only for critical teardown failures.

Final Checklist Before Deployment

  • Every connect() in initGui() has a matching disconnect() in
  • All QThread instances call quit() +
  • UI elements are removed via iface.removeToolBarIcon() and
  • Method is wrapped in try/except

Properly cleaning up plugin resources on QGIS shutdown is a deterministic, ordered process. When implemented correctly, your plugin will exit cleanly, leave no orphaned processes, and maintain project integrity across sessions.