Understand QGIS Signals and Slots in PyQGIS
Almost everything reactive in QGIS is a Qt signal. A layer announces that its features changed, the project announces that it was read, the canvas announces that the extent moved — and any amount of code can listen without those objects knowing anything about the listeners. Understanding the mechanism turns a large part of the QGIS API from a list of classes into a system you can hook into.
This recipe belongs to QGIS API Architecture in PyQGIS. It covers connecting and disconnecting, the two ways Python code accidentally breaks the connection, suppressing signals during bulk edits, and the signals that come up most often on projects, layers and the canvas.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer, and a Python console or plugin to experiment in.
- No Qt background required, but knowing that
connecttakes any callable helps.
Connect and disconnect
A signal is an attribute on an object; connecting attaches a callable to it.
from qgis.core import QgsProject
def on_layers_added(layers):
for layer in layers:
print(f"added {layer.name()} ({layer.crs().authid()})")
project = QgsProject.instance()
project.layersAdded.connect(on_layers_added)
Breakdown: The callable's signature must accept whatever the signal emits — here a list of layers. Connecting a function that takes no arguments to a signal that emits one raises at emission time, not at connection time, which is why a typo here surfaces much later than you would like. A plain module-level function is the easiest thing to connect and the easiest to disconnect.
Disconnecting requires the same callable object:
project.layersAdded.disconnect(on_layers_added)
Breakdown: Passing a different-but-equivalent function does nothing and raises TypeError in PyQt. That is the reason lambdas are hard to disconnect: lambda l: print(l) creates a new object every time it is written, so there is no way to name the one that was connected. Keeping a reference — self._handler = lambda l: ... — makes it possible, but a bound method is usually cleaner.
Calling disconnect() with no arguments removes every connection to that signal, including ones other code made. In a plugin's unload() that is tempting and wrong; disconnect only what you connected.
The two ways Python breaks the connection
Both are about object lifetime, and both produce the same symptom: the slot silently stops running.
A garbage-collected receiver. PyQt keeps only a weak reference to a bound method's object. If nothing else holds the object, it is collected and the connection quietly dies.
class Watcher:
def __init__(self, layer):
layer.featureAdded.connect(self.on_added)
def on_added(self, fid):
print("added", fid)
Watcher(layer) # collected immediately — the slot never fires
watcher = Watcher(layer) # kept alive — works
Breakdown: The first line creates a Watcher, connects it, and drops the only reference to it. The connection survives just long enough to be useless. In a plugin, storing handlers on self is what keeps them alive; in a script, a module-level variable does the same.
A lambda capturing a loop variable. Python closes over the variable, not its value, so every lambda in the loop sees the final value.
for layer in layers:
layer.willBeDeleted.connect(lambda: print(f"gone: {layer.name()}")) # all report the last layer
for layer in layers:
layer.willBeDeleted.connect(lambda name=layer.name(): print(f"gone: {name}")) # correct
Breakdown: The default-argument trick binds the value at definition time and is the standard fix. functools.partial does the same more explicitly. This bug is particularly nasty with signals because it produces plausible-looking output — every handler runs, they just all describe the wrong object.
Suppressing signals during bulk work
Every change emits, and a loop of ten thousand changes emits ten thousand times — each one repainting a canvas or refreshing a panel.
layer.blockSignals(True)
try:
layer.startEditing()
for feature in layer.getFeatures():
layer.changeAttributeValue(feature.id(), index, new_value)
layer.commitChanges()
finally:
layer.blockSignals(False)
layer.triggerRepaint()
Breakdown: blockSignals(True) suppresses every signal from that object until it is unblocked — including ones you do want, which is why the finally matters and why the explicit repaint afterwards is necessary. Note that blocked signals are dropped, not queued: listeners never learn about the individual changes, so a panel that maintains an incremental count must be told to refresh wholesale afterwards. For canvas work specifically, canvas.setRenderFlag(False) is the better tool because it suppresses drawing without suppressing notification.
Debouncing an expensive handler
Signals that fire continuously — canvas extent, selection during a rubber-band drag, attribute changes during a bulk edit — need the handler to run once when the activity settles rather than on every emission.
from qgis.PyQt.QtCore import QTimer
class DebouncedExtentWatcher:
def __init__(self, canvas, callback, delay_ms=250):
self.canvas = canvas
self.callback = callback
self.timer = QTimer()
self.timer.setSingleShot(True)
self.timer.setInterval(delay_ms)
self.timer.timeout.connect(self._fire)
canvas.extentsChanged.connect(self._schedule)
def _schedule(self):
self.timer.start() # restarts if already running
def _fire(self):
self.callback(self.canvas.extent(), self.canvas.scale())
def detach(self):
self.canvas.extentsChanged.disconnect(self._schedule)
self.timer.timeout.disconnect(self._fire)
Breakdown: start() on a running single-shot timer restarts it, so a burst of twenty extent changes during a pan results in exactly one callback a quarter of a second after the user stops. The timer must be stored on the instance — a local QTimer is collected as soon as the constructor returns, and then nothing ever fires, which is the same lifetime trap in a different costume. detach() exists because a plugin's unload() needs a way to leave the canvas as it found it.
Two hundred and fifty milliseconds is a good default: short enough to feel immediate, long enough that a continuous pan does not trigger it once per frame. For work measured in seconds rather than milliseconds, push it into a background task instead, because even a debounced handler blocks the interface while it runs.
The signals worth knowing
A handful come up constantly, and knowing them saves inventing polling loops.
On QgsProject: layersAdded, layersRemoved and layerWasAdded for the layer set; readProject and writeProject for hooking into load and save; cleared before a new project replaces the current one. On a vector layer: featureAdded, featureDeleted and attributeValueChanged during editing; editingStarted and editingStopped; selectionChanged for anything that follows the user's selection; dataChanged when the underlying source changed. On the canvas: extentsChanged after a pan or zoom, scaleChanged when the denominator moves, and mapCanvasRefreshed after a redraw completes.
iface.mapCanvas().extentsChanged.connect(
lambda: print(f"1:{iface.mapCanvas().scale():.0f}")
)
Breakdown: extentsChanged fires on every pan step, so a handler doing real work needs debouncing — a QTimer with a short single-shot interval, restarted on each emission, is the standard pattern. Connecting expensive work directly to this signal is the most common cause of a plugin that makes panning feel sticky. See connecting layer signals for the layer-side equivalents.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.16 LTR | 3.7 | Signal names used here all present; PyQt5 semantics. |
| 3.22 LTR | 3.9 | QgsProject.layersAdded and related signals unchanged. |
| 3.28 LTR | 3.9 | Some layer signals gained overloads; connect to the documented signature. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40+ | 3.12 | Qt6 builds appear; QtCore.pyqtSignal semantics are unchanged for consumers. |
Troubleshooting
- The slot never fires. The receiving object was garbage collected. Store a reference on
selfor at module level. - Every handler reports the last item. A lambda captured a loop variable. Bind it with a default argument.
disconnectraises TypeError. The callable passed is not the one that was connected. Keep the reference.- The slot fires many times per action. It was connected more than once. Disconnect before connecting, or guard with a flag.
- A bulk edit is unbearably slow. Signals are firing per change. Block them, or suspend canvas rendering.
- An exception in one slot broke the others. Slots run in connection order and an unhandled exception can stop the chain. Wrap slot bodies in try/except.
Conclusion
Keep a reference to anything whose bound method you connect, bind loop variables explicitly, disconnect exactly what you connected, and reach for blockSignals or the canvas render flag before writing a loop that changes thousands of things. Most of the QGIS API becomes reactive once these four habits are automatic.
Frequently Asked Questions
What is the difference between a signal and a slot in Python?
In PyQt, any callable can act as a slot — a function, a bound method, a lambda. The @pyqtSlot decorator exists for performance and for cross-thread connections, and is optional for ordinary use.
Can I connect one signal to another?
Yes, signal_a.connect(signal_b) chains them, which is occasionally useful for re-emitting a lower-level event through a plugin's own public signal.
Do signals work across threads? Yes, and Qt queues them automatically when the receiver lives in a different thread — which is what makes QgsTask safe to report progress from a worker.
How do I find what signals a class has?dir() on the object shows them, or help() in the console. The API documentation lists them per class, and exploring the API with dir and help covers the technique.