Signal and Slot Event Handling in QGIS
Master Qt signal/slot architecture in PyQGIS: connect emitters, manage connection lifecycles, debounce high-frequency events, and avoid the pitfalls that…
Signal and slot event handling is the reactive backbone of every production PyQGIS plugin. Whether you are synchronizing UI state with live attribute edits, tracking project load/save cycles, or responding to canvas interactions without polling, Qt’s signal/slot mechanism gives you a clean, decoupled path from event source to handler. This guide is part of the PyQGIS Core Architecture & Data Handling guide and builds directly on the object lifecycle and registry concepts explained there. By the end you will know how to identify the right emitter, write type-safe slots, manage connection lifetimes correctly, and avoid the failure modes that bring down long-running automation.
Prerequisites
Before implementing reactive patterns, confirm that your environment meets these requirements:
- QGIS 3.28 LTR or 3.34+ — signal signatures stabilized in 3.x and later releases backport important threading fixes
- Python 3.8+ — required for
functools.partialpatterns and the type-hint syntax used throughout this guide - PyQt5 or PyQt6 — matching the Qt version your QGIS build was compiled against; mixing versions causes silent connection failures
- Familiarity with Python decorators,
lambda, andweakref— used extensively in the advanced patterns below - A working understanding of how layers are registered and retrieved via Working with QgsProject and Layer Registry, because most emitters live there
Test all signal connections in a clean QGIS session. Residual state from a previous plugin load can leave phantom connections that interfere with your new ones. In production plugins, group all connection setup and teardown inside dedicated controller classes rather than scattering connect() calls across UI modules.
Architecture: How Qt Signal Dispatch Works in PyQGIS
Qt’s meta-object compiler (MOC) bakes runtime type information into every QObject subclass at C++ compile time. This metadata powers the signal/slot registry: when an object calls emit, Qt looks up every registered slot for that signal and invokes them in registration order. PyQt/PySide expose this machinery through signal.connect(callable), mapping C++ signals directly to Python callables without requiring any Python subclassing.
The dispatch model has three modes that PyQGIS developers must distinguish:
| Connection type | When used | Implication |
|---|---|---|
Qt.AutoConnection (default) | Same thread | Slot executes synchronously inside the emit call |
Qt.AutoConnection (default) | Cross-thread | Slot is posted as a queued event and executes in the receiver’s thread’s event loop |
Qt.QueuedConnection (explicit) | Always cross-thread | Slot always deferred; safe for GUI updates from background threads |
Key rules for PyQGIS implementations:
- Signals are emitted synchronously by default when emitter and slot share a thread; the slot completes before
emitreturns - Unconnected signals incur zero overhead — Qt short-circuits the dispatch immediately
- Slots must match the signal’s C++ parameter types; mismatches raise
TypeErrorat theconnect()call or at first emit - Circular signal→slot→emit→signal chains produce infinite recursion; guard with
blockSignals()(see Pitfalls below)
Step-by-Step Implementation
Each step below is a discrete engineering decision. Follow the sequence in order — skipping “identify the emitter” is the single most common source of connections that never fire.
Step 1 — Identify the Signal Emitter
Every QGIS object that inherits from QObject can emit signals. The emitter hierarchy for common use cases is:
QgsProject.instance()— project-level lifecycle events:layersAdded,layersRemoved,readProject,writeProject,crsChangedQgsVectorLayer/QgsRasterLayer— data-change events:attributeValueChanged,featureAdded,featureDeleted,editingStarted,editingStopped,rendererChangedQgsMapCanvas— canvas events:mapCanvasRefreshed,layersChanged,xyCoordinates,renderStartingQgsLayerTreeGroup/QgsLayerTreeLayer— tree-model events:visibilityChanged,nameChanged
from qgis.core import QgsProject
project = QgsProject.instance()
# Always use the singleton — never a freshly constructed QgsProject()
# The singleton is the live registry; a new instance holds no layers.
Always connect to the live registry singleton returned by QgsProject.instance(). Connecting to a local QgsProject() instance is a silent no-op: the object exists but never receives layer events.
Step 2 — Inspect the Signal Signature
Verify the exact C++ parameter types before writing your slot. The PyQGIS API reference documents each signal’s overloads. Common signatures to memorise:
| Signal | Parameters |
|---|---|
QgsProject.layersAdded | list[QgsMapLayer] |
QgsProject.layersRemoved | list[str] (layer IDs) |
QgsVectorLayer.attributeValueChanged | fid: int, idx: int, value: Any |
QgsVectorLayer.featureAdded | fid: int |
QgsMapCanvas.xyCoordinates | point: QgsPointXY |
You can also enumerate signals at runtime during development:
# Development introspection — do not ship in production plugins
import re
def list_signals(obj: object) -> list[str]:
"""Return names of Qt signals exposed on a QObject instance."""
meta = obj.metaObject()
return [
meta.method(i).name().data().decode()
for i in range(meta.methodCount())
if meta.method(i).methodType() == meta.method(i).Signal
]
layer_signals = list_signals(iface.activeLayer())
Step 3 — Define a Type-Safe Slot
Write a Python callable whose parameter list exactly matches the signal. Use type hints for IDE support and static analysis. Keep slots lightweight: the event loop blocks while the slot executes, so offload any expensive computation to a QgsTask.
from typing import Any
def on_attribute_changed(fid: int, field_index: int, new_value: Any) -> None:
"""
Slot for QgsVectorLayer.attributeValueChanged.
Args:
fid: Feature ID whose attribute changed.
field_index: Zero-based index of the changed field.
new_value: The value written by the edit command.
"""
# Validate before acting — new_value may be None if the field is nullable
if new_value is None:
return
print(f"Feature {fid}: field[{field_index}] → {new_value!r}")
When you need to capture external context (a layer ID, a UI reference, a configuration dict), use functools.partial rather than a lambda. partial produces a proper callable object that can be disconnected by identity; lambdas are anonymous and cannot be disconnected without storing an explicit reference.
import functools
from qgis.core import QgsVectorLayer
def on_attribute_changed_for_layer(layer_id: str, fid: int, field_index: int, new_value: Any) -> None:
"""Slot pre-bound to a specific layer ID for multi-layer tracking."""
print(f"[{layer_id}] feature {fid}: field[{field_index}] = {new_value!r}")
layer: QgsVectorLayer = ...
bound_slot = functools.partial(on_attribute_changed_for_layer, layer.id())
layer.attributeValueChanged.connect(bound_slot)
# Store bound_slot — you need the exact same object to disconnect later
Step 4 — Connect and Manage the Connection
Use signal.connect(slot) to register your handler. For connections that span the entire plugin lifetime, store the slot reference as an instance attribute. For temporary connections (e.g., listening for a single edit session), disconnect as soon as the condition is met.
from qgis.core import QgsVectorLayer
from qgis.PyQt.QtCore import Qt
class EditTracker:
"""Tracks attribute edits on a single vector layer."""
def __init__(self, layer: QgsVectorLayer) -> None:
self._layer = layer
self._changes: list[tuple[int, int, Any]] = []
# Connect — keep the slot as an attribute so we can disconnect it
self._layer.attributeValueChanged.connect(self._on_attribute_changed)
def _on_attribute_changed(self, fid: int, field_index: int, new_value: Any) -> None:
"""Record each edit for later batch processing."""
self._changes.append((fid, field_index, new_value))
def teardown(self) -> None:
"""Disconnect all signals. Call from plugin.unload() or on layer removal."""
try:
self._layer.attributeValueChanged.disconnect(self._on_attribute_changed)
except (TypeError, RuntimeError):
# Already disconnected or underlying C++ object was deleted
pass
For cross-thread safety — for example when emitting from a QgsTask back to the main GUI thread — pass Qt.QueuedConnection explicitly. This instructs Qt to post the call as a queued event rather than invoking the slot synchronously in the background thread, which would immediately violate Qt’s GUI thread rule.
from qgis.PyQt.QtCore import Qt
# Safe cross-thread connection: slot will execute on main thread's event loop
some_task.progressChanged.connect(self._update_progress_bar, Qt.QueuedConnection)
When handling Coordinate Transformations and CRS Handling in real time, CRS-change signals cascade across every layer and the canvas. Temporarily block redundant emissions with layer.blockSignals(True) while applying bulk CRS updates, then restore the previous state immediately afterward.
Advanced Patterns
Debouncing High-Frequency Signals
Some signals fire tens of times per second: QgsMapCanvas.xyCoordinates during mouse movement, QgsVectorLayer.attributeValueChanged during batch attribute imports, or QgsLayerTreeLayer.visibilityChanged during programmatic layer toggles. Connecting an expensive slot directly to these signals degrades the UI. The standard solution is a debounce wrapper using QTimer.singleShot().
from qgis.PyQt.QtCore import QTimer
class DebouncedSlot:
"""
Collapses rapid successive signal emissions into a single deferred call.
Usage:
debounced = DebouncedSlot(my_expensive_function, delay_ms=300)
layer.attributeValueChanged.connect(lambda *_: debounced())
"""
def __init__(self, callback: callable, delay_ms: int = 250) -> None:
self._callback = callback
self._timer = QTimer()
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._callback)
self._delay = delay_ms
def __call__(self) -> None:
# Restart the timer on every call; callback fires only when calls stop
self._timer.start(self._delay)
def cancel(self) -> None:
"""Stop a pending delayed call without firing it."""
self._timer.stop()
# Example: refresh a summary panel at most once per 300 ms of editing activity
debounced_refresh = DebouncedSlot(refresh_summary_panel, delay_ms=300)
layer.attributeValueChanged.connect(lambda *_: debounced_refresh())
Weak-Reference Slots for Decoupled Components
When a slot belongs to a UI widget that can be closed while the emitter (a layer or the project) lives on, you risk calling into a deleted Python object. Python’s weakref module lets you register a slot that silently disconnects itself when the target is garbage-collected.
import weakref
from typing import Callable
from qgis.core import QgsProject
def make_weak_slot(instance: object, method_name: str, signal) -> Callable:
"""
Return a callable that forwards to instance.method_name via a weak reference.
Auto-disconnects from signal when instance is garbage-collected.
Args:
instance: The object whose method should be called.
method_name: The method to call on instance.
signal: The Qt signal to disconnect from when instance dies.
Returns:
A callable suitable for use as a slot.
"""
weak_ref = weakref.ref(instance)
def slot(*args, **kwargs):
obj = weak_ref()
if obj is None:
# Instance was garbage-collected — disconnect to avoid future calls
try:
signal.disconnect(slot)
except (TypeError, RuntimeError):
pass
return
getattr(obj, method_name)(*args, **kwargs)
return slot
class SummaryPanel:
def on_layers_added(self, layers):
print(f"Panel sees {len(layers)} new layers")
panel = SummaryPanel()
slot = make_weak_slot(panel, "on_layers_added", QgsProject.instance().layersAdded)
QgsProject.instance().layersAdded.connect(slot)
# When panel goes out of scope, the next layersAdded emission silently disconnects
Custom Signals on Plugin Objects
Beyond consuming built-in QGIS signals, plugins that expose an internal event bus benefit from declaring their own signals on QObject subclasses. This keeps cross-component communication within your plugin decoupled and testable in isolation.
from qgis.PyQt.QtCore import QObject, pyqtSignal
class ProcessingController(QObject):
"""
Internal controller that emits domain-specific signals.
Other plugin components subscribe here rather than coupling to QGIS objects directly.
"""
# Custom signals — declare at class level, not in __init__
feature_processed = pyqtSignal(int, str) # (feature_id, status_message)
batch_complete = pyqtSignal(int) # (total_features_processed)
def process_batch(self, layer) -> None:
"""Run batch attribute update and emit progress/completion signals."""
count = 0
for feature in layer.getFeatures():
# … perform processing …
self.feature_processed.emit(feature.id(), "ok")
count += 1
self.batch_complete.emit(count)
Declaring signals this way makes them visible to the Qt meta-object system: they can be connected from any other QObject, routed across threads with Qt.QueuedConnection, and inspected by testing harnesses that use QSignalSpy.
Pitfalls and Debugging
Dangling connections after plugin unload. Python’s garbage collector does not sever Qt connections. When plugin.unload() returns, any slot still connected to a project or layer signal will be invoked on a deleted Python object the next time the signal fires, producing a RuntimeError: wrapped C++ object … has been deleted or a segfault. Fix: implement a teardown() method (as shown in Step 4) and call it from unload().
Connecting to a local layer copy. Constructing QgsVectorLayer("path/to/file.gpkg", "name", "ogr") returns a fresh object that is not in the registry. Signals emitted by the registry’s copy of that layer will never reach your slot. Fix: always retrieve the live layer from QgsProject.instance().mapLayer(layer_id).
Missing disconnect on blockSignals restoration. blockSignals(True) returns the previous blocking state, which may already be True. Restoring with a hard-coded blockSignals(False) overrides a legitimate upstream block. Fix: always capture the return value and restore it.
def safe_attribute_update(layer, fid: int, field_index: int, value: Any) -> None:
"""Update an attribute without re-triggering attributeValueChanged."""
was_blocked = layer.blockSignals(True) # capture previous state
try:
layer.changeAttributeValue(fid, field_index, value)
finally:
layer.blockSignals(was_blocked) # restore, not force-false
Circular emission loops. A slot that modifies the same attribute it was triggered on re-emits attributeValueChanged, calling itself recursively. Without a guard this produces either infinite recursion (Python stack overflow) or a hung QGIS session. Fix: use blockSignals() around the re-entrant update, or maintain a _processing flag.
Cross-thread GUI updates. Updating a widget from inside a QgsTask without Qt.QueuedConnection triggers Qt’s cross-thread warning and can produce random crashes. Fix: emit a custom signal from the task with Qt.QueuedConnection, or use QgsMessageLog (which is thread-safe) for status reporting, and confine all widget updates to the main thread.
Lambda slots that cannot be disconnected. Calling signal.connect(lambda fid, idx, val: self._handle(fid)) creates an anonymous object with no stable identity. signal.disconnect(lambda …) with a different lambda object will fail. Fix: assign the lambda to a named variable before connecting, or use functools.partial.
Event filters vs. signals — choosing the right tool. Signals communicate completed state changes. Raw input events (mouse press, key down, scroll wheel) arrive before the target widget processes them, which requires an event filter, not a signal. For intercepting canvas input before QGIS routing acts on it, see Implementing custom event filters for QGIS map canvas interactions.
Conclusion
Qt’s signal/slot mechanism, exposed through PyQt in PyQGIS, gives you a structured, type-safe, and performance-efficient way to react to every state change in the QGIS object graph — from project load/save cycles down to individual attribute edits and map canvas redraws. The discipline required is small but non-negotiable: always identify the live emitter from the registry, match the signal signature exactly, store slot references for explicit teardown, and guard re-entrant updates with blockSignals. Apply debouncing for high-frequency events and weak references for UI components with shorter lifetimes than their emitters. With these practices embedded in your plugin architecture, your event-driven code will remain stable across arbitrarily long QGIS sessions and production data volumes.
Related
- PyQGIS Core Architecture & Data Handling — parent guide covering the full PyQGIS execution model
- Working with QgsProject and Layer Registry — how layers are stored and retrieved (the source of most emitters)
- Coordinate Transformations and CRS Handling — CRS-change signals and when to suppress cascading redraws
- Spatial Indexing and Query Optimization — build a
QgsSpatialIndexonce and invalidate it via layer signals - Implementing custom event filters for QGIS map canvas interactions — intercept raw input events before QGIS routes them