Plugin Lifecycle and Resource Management in QGIS Python Plugins
Master the four-phase plugin lifecycle in QGIS: initialization, runtime state management, graceful teardown, and post-execution persistence. Production-ready…
Unstable plugins — ones that crash on reload, leak toolbar buttons after disable, or silently corrupt project state — share a single root cause: their authors treated QGIS as a plain Python environment rather than a C++/Python hybrid with strict memory ownership rules. This guide, part of the Plugin Development & UI Integration reference, gives you a structured four-phase lifecycle model, production-ready code patterns, and a diagnostic toolkit to build extensions that stay stable across long QGIS sessions, disable/enable cycles, and enterprise deployment at scale.
Prerequisites
Before implementing lifecycle controls, verify your environment meets these baseline requirements:
- QGIS 3.28 LTR or newer (Python 3.8+ embedded environment)
- Familiarity with the standard plugin directory structure:
__init__.py,metadata.txt, and a main plugin class - Working knowledge of PyQt5/PyQt6 object ownership — specifically the parent-child deletion contract
- Understanding of the Python/C++ SIP boundary: Python wrappers around
QgsMapCanvas,QgsVectorLayer, andQgsProcessingAlgorithmdo not own the underlying C++ objects - Access to the QGIS Python Console or Plugin Builder 3 for scaffolding
The QgisInterface is the single authoritative gateway between your plugin and QGIS core services. Every UI registration, signal connection, and teardown action flows through it.
Architecture: How the Qt/SIP Ownership Model Works
QGIS plugins operate at three distinct ownership layers: the Qt C++ object tree, the SIP wrapper layer, and the Python reference graph. Failures happen at the seam between layers.
When Qt deletes a C++ object (for example, when a parent widget is destroyed), any SIP wrapper that still exists becomes a “zombie” proxy pointing at freed memory. If your Python code then calls a method on that proxy, QGIS crashes with a segfault. The fix is disciplined teardown: remove Python references before Qt can delete the underlying object, and use weakref.ref() for objects owned by QGIS rather than your plugin.
The Four-Phase Lifecycle
A robust plugin observes a strict four-phase contract. Each phase has defined entry points, resource expectations, and cleanup obligations.
Phase 1: Initialization and UI Registration
__init__ and initGui handle bootstrap operations. Register UI elements, connect signals, and initialize state variables here. The single most important rule: never perform heavy I/O, network calls, or layer loading inside initGui. QGIS calls initGui synchronously during startup; blocking the main thread even for a few hundred milliseconds delays the entire application launch and may trigger watchdog timeouts.
Always assign explicit parent widgets. Passing iface.mainWindow() as the parent to custom panels and dialogs ensures Qt’s object tree can traverse and destroy them correctly during teardown. This is the most commonly missed step when designing Qt dialogs and form widgets, and it is what leaves floating windows behind after plugin deactivation.
from __future__ import annotations
from typing import Optional
from qgis.PyQt.QtWidgets import QAction, QToolBar
from qgis.PyQt.QtGui import QIcon
from qgis.core import QgsMessageLog, Qgis
from qgis.gui import QgisInterface
class MyPlugin:
"""Production-grade plugin entry point with explicit lifecycle phases."""
def __init__(self, iface: QgisInterface) -> None:
self.iface: QgisInterface = iface
# Declare all instance attributes here; avoids AttributeError in unload()
self.action: Optional[QAction] = None
self.toolbar: Optional[QToolBar] = None
self._is_initialized: bool = False
def initGui(self) -> None:
"""Phase 1: register UI elements and connect signals.
Must return within milliseconds — no I/O, no network, no layer loading.
"""
# 1. Create action with explicit parent — prevents orphaned QAction
self.action = QAction(
QIcon(":/plugins/myplugin/icon.png"),
"My Tool",
self.iface.mainWindow(), # parent — critical
)
self.action.triggered.connect(self._run)
# 2. Add to toolbar with an objectName for session restore
self.toolbar = self.iface.addToolBar("My Plugin Toolbar")
self.toolbar.setObjectName("MyPluginToolbar")
self.toolbar.addAction(self.action)
# 3. Register menu entry
self.iface.addPluginToMenu("&My Plugin", self.action)
self._is_initialized = True
QgsMessageLog.logMessage("initGui complete", "MyPlugin", Qgis.Info)
Phase 2: Runtime State and Signal Routing
Once active, the plugin connects to QGIS events such as iface.mapCanvas().layersChanged and QgsProject.instance().layersAdded. The critical hazard here is reference cycle formation: a QGIS C++ object holds a pointer to a Python callable, and that callable closes over self, which holds the C++ object. Python’s cyclic garbage collector cannot break cycles that cross the SIP boundary.
Use weakref.ref() for any layer or canvas reference your plugin does not own. Maintain a signal registry — a list of (sender, signal, slot) tuples — so teardown can disconnect exactly what was connected, without guessing.
When building data pipelines, coordinate carefully with asynchronous task execution via QgsTask: background tasks must never retain strong references to UI components. Route task results back to the main thread via signals rather than direct method calls.
import weakref
from typing import Callable, Any
from qgis.core import QgsMapLayer, QgsProject, QgsMessageLog, Qgis
from qgis.PyQt.QtCore import pyqtSlot
class SignalRegistry:
"""Track connected (sender, signal, slot) triples for deterministic teardown."""
def __init__(self) -> None:
self._connections: list[tuple[Any, Any, Callable]] = []
def connect(self, sender: Any, signal: Any, slot: Callable) -> None:
"""Connect and register for later disconnection."""
signal.connect(slot)
self._connections.append((sender, signal, slot))
def disconnect_all(self) -> None:
"""Reverse all registered connections; safe to call multiple times."""
for _sender, signal, slot in reversed(self._connections):
try:
signal.disconnect(slot)
except (TypeError, RuntimeError):
pass # already disconnected or C++ object deleted
self._connections.clear()
class PluginState:
"""Lightweight runtime state container using weak layer references."""
def __init__(self) -> None:
# weakref.ref — does not block QgsProject from deleting layers
self._active_layer_refs: set[weakref.ref] = set()
def track_layer(self, layer: QgsMapLayer) -> None:
"""Register a layer reference without taking strong ownership."""
self._active_layer_refs.add(weakref.ref(layer))
def active_layers(self) -> list[QgsMapLayer]:
"""Return currently live layers, pruning stale weak references."""
live: list[QgsMapLayer] = []
stale: set[weakref.ref] = set()
for ref in self._active_layer_refs:
obj = ref()
if obj is None:
stale.add(ref)
else:
live.append(obj)
self._active_layer_refs -= stale
return live
Use the SignalRegistry in your main plugin class:
def _connect_runtime_signals(self) -> None:
"""Phase 2 entry: wire up QGIS event subscriptions."""
self._signals = SignalRegistry()
self._state = PluginState()
canvas = self.iface.mapCanvas()
project = QgsProject.instance()
self._signals.connect(canvas, canvas.layersChanged, self._on_layers_changed)
self._signals.connect(project, project.readProject, self._on_project_read)
@pyqtSlot()
def _on_layers_changed(self) -> None:
"""Respond to layer stack changes on the map canvas."""
# safe: canvas is owned by the main window, outlives this slot
active = self._state.active_layers()
QgsMessageLog.logMessage(
f"Tracking {len(active)} layers", "MyPlugin", Qgis.Info
)
Phase 3: Graceful Teardown and Memory Release
unload() is the most important method in any plugin. It is called when the user disables the plugin or when QGIS shuts down, and it must reverse every action performed in initGui in strict inverse order:
- Disconnect all custom signals from QGIS core objects.
- Remove custom toolbars, menus, and dock widgets from
iface. - Set Python-side references to C++ objects to
None. - Clear application-level caches and temporary files.
Failure to follow this order creates dangling C++ pointers that cause segfaults on the next plugin enable cycle. For a detailed treatment of shutdown-specific edge cases, see properly cleaning up plugin resources on QGIS shutdown.
Never call QgsProject.instance().removeAllMapLayers() from unload() — your plugin may only remove resources it explicitly created.
from qgis.core import QgsProject, QgsMessageLog, Qgis
def unload(self) -> None:
"""Phase 3: deterministic teardown in strict inverse-init order.
This is the authoritative cleanup gate — do NOT rely on __del__.
"""
if not self._is_initialized:
return
# Step 1: disconnect all signal subscriptions
if hasattr(self, "_signals"):
self._signals.disconnect_all()
# Step 2: remove UI elements from the QGIS main window
if self.action is not None:
self.iface.removeToolBarIcon(self.action)
self.iface.removePluginMenu("&My Plugin", self.action)
if self.toolbar is not None:
self.toolbar.deleteLater() # schedule Qt deletion on next event loop tick
# Step 3: clear Python references to C++ objects
self.action = None
self.toolbar = None
# Step 4: flush state and caches
if hasattr(self, "_state"):
self._state._active_layer_refs.clear()
self._is_initialized = False
QgsMessageLog.logMessage("unload complete", "MyPlugin", Qgis.Info)
Phase 4: Post-Execution Validation and State Persistence
Before unload returns, persist user-facing settings to QSettings. This ensures Plugin Development & UI Integration workflows remain seamless across QGIS restarts without forcing users to reconfigure thresholds, file paths, or API endpoints.
from qgis.PyQt.QtCore import QSettings
def _persist_settings(self) -> None:
"""Serialize session state to QSettings before unload completes."""
settings = QSettings()
settings.beginGroup("MyPlugin")
settings.setValue("last_output_dir", self._last_output_dir)
settings.setValue("tolerance_meters", self._tolerance)
settings.endGroup()
def _restore_settings(self) -> None:
"""Restore persisted state after initGui completes."""
settings = QSettings()
settings.beginGroup("MyPlugin")
self._last_output_dir = settings.value("last_output_dir", "")
self._tolerance = float(settings.value("tolerance_meters", 0.001))
settings.endGroup()
Advanced Patterns
Composable Teardown with Context Managers
For plugins with many optional subsystems (e.g., custom dock panels, layer decorators, processing providers), manage each subsystem’s lifecycle independently using a context-manager pattern. This makes teardown failures isolatable and prevents a single broken subsystem from blocking the rest of unload().
from contextlib import contextmanager
from typing import Generator
from qgis.core import QgsMessageLog, Qgis
@contextmanager
def safe_teardown(name: str) -> Generator[None, None, None]:
"""Wrap a teardown block; log failures without propagating them.
Usage:
with safe_teardown("dock panel"):
self.dock.close()
self.iface.removeDockWidget(self.dock)
"""
try:
yield
except Exception as exc: # noqa: BLE001
QgsMessageLog.logMessage(
f"Teardown failed for '{name}': {exc}",
"MyPlugin",
Qgis.Warning,
)
def unload(self) -> None:
"""Composable teardown using safe_teardown context manager."""
with safe_teardown("signal registry"):
self._signals.disconnect_all()
with safe_teardown("dock panel"):
if self.dock is not None:
self.iface.removeDockWidget(self.dock)
self.dock.deleteLater()
self.dock = None
with safe_teardown("toolbar"):
if self.toolbar is not None:
self.toolbar.deleteLater()
self.toolbar = None
Lazy Initialization for Heavy Subsystems
Processing providers, database connections, and custom renderer registries can be initialized lazily — on first use rather than in initGui. This keeps startup time minimal and avoids allocating resources for features the user never invokes. Coordinate this with building custom processing algorithms when your plugin registers a provider.
from typing import Optional
from qgis.core import QgsApplication, QgsProcessingProvider
class MyPlugin:
def __init__(self, iface) -> None:
self.iface = iface
self._provider: Optional[QgsProcessingProvider] = None
def _ensure_provider(self) -> QgsProcessingProvider:
"""Lazy-initialize the processing provider on first algorithm run."""
if self._provider is None:
self._provider = MyProcessingProvider()
QgsApplication.processingRegistry().addProvider(self._provider)
return self._provider
def unload(self) -> None:
if self._provider is not None:
QgsApplication.processingRegistry().removeProvider(self._provider)
self._provider = None
Dock Widget State Preservation
Custom dock widgets should preserve their visibility and geometry across sessions. QGIS’s QSettings integration makes this straightforward, but the geometry must be saved before deleteLater() is called.
from qgis.PyQt.QtWidgets import QDockWidget
from qgis.PyQt.QtCore import QSettings, Qt
def _save_dock_state(self, dock: QDockWidget) -> None:
"""Save dock visibility and geometry before teardown."""
settings = QSettings()
settings.beginGroup("MyPlugin/DockPanel")
settings.setValue("visible", dock.isVisible())
settings.setValue("floating", dock.isFloating())
settings.setValue("geometry", dock.saveGeometry())
area = self.iface.mainWindow().dockWidgetArea(dock)
settings.setValue("area", int(area))
settings.endGroup()
def _restore_dock_state(self, dock: QDockWidget) -> None:
"""Restore dock state after initGui registers the widget."""
settings = QSettings()
settings.beginGroup("MyPlugin/DockPanel")
if settings.value("visible", True, type=bool):
dock.show()
if geo := settings.value("geometry"):
dock.restoreGeometry(geo)
area = Qt.DockWidgetArea(settings.value("area", int(Qt.RightDockWidgetArea), type=int))
self.iface.mainWindow().addDockWidget(area, dock)
settings.endGroup()
Pitfalls and Debugging
-
Crash on plugin re-enable after disable. Root cause: Python still holds a reference to a C++ object deleted during the previous session. Fix: set all
self.widget,self.action, andself.toolbarattributes toNoneinunload(), and verify withsip.isdeleted(obj)before accessing wrapped objects. -
Ghost toolbar buttons after unload. Root cause:
removeToolBarIconwas called but theQToolBaritself was not removed viaself.toolbar.deleteLater(). Fix: calldeleteLater()on the toolbar object after clearing its actions. -
Signal connected twice. Root cause:
initGuiwas called without a precedingunload()(possible during hot-reload workflows). Fix: use theSignalRegistrypattern and always guardinitGuiwithif not self._is_initialized. -
Segfault when iterating layers in
unload(). Root cause: the plugin holds strong Python references toQgsVectorLayerobjects thatQgsProject.instance()has already deleted. Fix: store all layer references viaweakref.ref(), and callref()before use to check liveness. -
TypeError: disconnect() failedspam in the log. Root cause: a signal was connected inside a conditional branch that was never entered, so the matching disconnect call finds nothing. Fix: usetry/except (TypeError, RuntimeError): passaround everysignal.disconnect()call, or check connection existence first with theSignalRegistry. -
SIP
RuntimeError: wrapped C++ object of type X has been deleted. Root cause: a Python slot is invoked after the underlying Qt object was deleted by its C++ parent. Fix: useQObject.destroyedto set the Python reference toNonebefore the slot can fire on a dead object. -
QgsTaskresult delivered to deleted UI widget. Root cause: the widget was torn down while the background task was still running. Fix: always checkself._is_initializedat the top of any slot that handles task results, and cancel running tasks before returning fromunload(). See asynchronous task execution with QgsTask for the correct cancellation pattern.
Conclusion
A QGIS plugin that survives long sessions, disable/enable cycles, and multi-project workflows is one that treats initialization and teardown as a matched pair: every resource acquired in initGui has a corresponding release in unload, in strict inverse order. Adopting the SignalRegistry pattern, weakref.ref() for layer references, safe_teardown context managers for optional subsystems, and QSettings-backed persistence for user state eliminates the majority of stability issues reported against production QGIS extensions. Apply these patterns consistently, and your plugin will be as reliable as the QGIS core itself.
Related
- Designing Qt Dialogs and Form Widgets — parent assignment, modal vs modeless, and layout patterns for custom panels
- Asynchronous Task Execution with QgsTask — background workers, cancellation, and safe result delivery to the UI thread
- Building Custom Processing Algorithms — provider registration, parameter definitions, and algorithm teardown
- Integrating Toolbars and Menu Actions —
QActionconstruction, icon paths, and menu group management - Properly Cleaning Up Plugin Resources on QGIS Shutdown — shutdown-specific edge cases and project close events