Connect to Layer and Project Signals in PyQGIS
A plugin that reads the project once and caches what it found will be wrong within minutes: the user adds a layer, removes another, starts an edit session, changes the selection. Signals are how QGIS tells you those things happened — and the two ways they go wrong are both about lifetime. Act on a layer after it has been destroyed and QGIS crashes. Forget to disconnect on unload and a reloaded plugin runs every handler twice.
This page is a focused recipe within Custom Map Tools and Canvas Interaction. It covers the signals worth knowing, the crucial difference between the two layer-removal signals, connecting to layers that do not exist yet, and disconnecting cleanly.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- A plugin skeleton with
initGui()andunload()— see Plugin Boilerplate & Structure. - Familiarity with Qt signals and slots; Qt Designer for GIS Interfaces covers the basics.
The signals worth knowing
QgsProject reports structural changes; a QgsVectorLayer reports changes to itself.
| Signal | Emitted by | Carries | Use it for |
|---|---|---|---|
layersAdded | project | list of layers | wiring up a newly loaded layer |
layerWillBeRemoved | project | the layer | releasing your reference safely |
layersRemoved | project | list of IDs | cleaning up keyed caches |
cleared | project | nothing | resetting when a new project opens |
selectionChanged | layer | added / removed IDs | keeping a panel in step |
editingStarted / editingStopped | layer | nothing | enabling or disabling actions |
attributeValueChanged | layer | fid, index, value | live validation |
layerModified | layer | nothing | marking a view dirty |
from qgis.core import QgsProject
project = QgsProject.instance()
project.layersAdded.connect(self.on_layers_added)
project.layerWillBeRemoved.connect(self.on_layer_will_be_removed)
project.cleared.connect(self.on_project_cleared)
Breakdown: layersAdded passes a list, not a single layer, because loading a multi-layer GeoPackage adds several at once — a handler written for one silently processes only the first. cleared fires when a new project is opened or the current one is closed, and is the signal that should reset every cache your plugin holds; without it a plugin keeps showing data from the previous project.
Never touch a removed layer
The two removal signals look interchangeable and are not.
class MyPlugin:
def __init__(self, iface):
self.iface = iface
self.tracked = {} # layer id -> some cached state
def on_layer_will_be_removed(self, layer_id):
layer = QgsProject.instance().mapLayer(layer_id)
if layer is not None:
try:
layer.selectionChanged.disconnect(self.on_selection_changed)
except TypeError:
pass
self.tracked.pop(layer_id, None)
def on_layers_removed(self, layer_ids):
for layer_id in layer_ids:
self.tracked.pop(layer_id, None) # ids only — do not look them up
Breakdown: layerWillBeRemoved fires before destruction and passes the layer's ID, which can still be resolved to a live object — this is the only safe moment to disconnect signals you attached to that layer. layersRemoved fires afterwards; calling mapLayer() from it returns None at best, and holding a stale Python wrapper and calling a method on it crashes the application. Treating the IDs as opaque strings in that handler is the rule.
Connect to layers that do not exist yet
A plugin loaded before the user opens a project has no layers to connect to. Wiring new layers as they arrive covers both cases with one code path.
from qgis.core import QgsProject, QgsVectorLayer
class MyPlugin:
def initGui(self):
project = QgsProject.instance()
project.layersAdded.connect(self.wire_layers)
self.wire_layers(list(project.mapLayers().values())) # catch existing ones
def wire_layers(self, layers):
for layer in layers:
if not isinstance(layer, QgsVectorLayer):
continue
layer.selectionChanged.connect(self.on_selection_changed)
layer.editingStopped.connect(self.on_editing_stopped)
def on_selection_changed(self, selected, deselected, clear_and_select):
layer = self.sender()
self.iface.messageBar().pushInfo(
layer.name(), f"{layer.selectedFeatureCount()} selected"
)
Breakdown: Calling wire_layers() with the current layers immediately after connecting means the plugin behaves the same whether it was enabled before or after the project loaded. The isinstance check skips rasters, which do not have these signals. self.sender() inside a slot returns the object that emitted it, which is how one handler serves every layer without a lambda per connection — and lambdas here would be a leak, since each would hold a reference to its layer.
selectionChanged passes three arguments: the newly selected IDs, the deselected IDs, and whether the selection was replaced wholesale. A handler that only needs the count can ignore them, but for an incremental view they avoid re-reading the whole selection.
Disconnect on unload
def unload(self):
project = QgsProject.instance()
for signal, slot in (
(project.layersAdded, self.wire_layers),
(project.layerWillBeRemoved, self.on_layer_will_be_removed),
(project.cleared, self.on_project_cleared),
):
try:
signal.disconnect(slot)
except TypeError:
pass
for layer in project.mapLayers().values():
if isinstance(layer, QgsVectorLayer):
try:
layer.selectionChanged.disconnect(self.on_selection_changed)
layer.editingStopped.disconnect(self.on_editing_stopped)
except TypeError:
pass
Breakdown: Every connection made in initGui() or wire_layers() needs a matching disconnect, including the per-layer ones. disconnect() raises TypeError when the connection is already gone — which happens legitimately, for example when a layer was removed earlier — so the guard makes unload() idempotent rather than fragile. Iterating the signal/slot pairs keeps the list visible in one place, which is the practical defence against adding a connection and forgetting its disconnect. This discipline is what makes Reload a QGIS Plugin Without Restarting usable during development.
Do not do heavy work in a signal handler
Signals fire on the main thread, synchronously, in the middle of whatever QGIS was doing. A handler that reads a large layer or hits a network freezes the interface — and attributeValueChanged in particular can fire thousands of times during a single bulk edit.
from qgis.PyQt.QtCore import QTimer
class MyPlugin:
def initGui(self):
self._refresh_timer = QTimer(self.iface.mainWindow())
self._refresh_timer.setSingleShot(True)
self._refresh_timer.setInterval(200)
self._refresh_timer.timeout.connect(self.recompute)
def on_attribute_changed(self, fid, index, value):
self._refresh_timer.start() # restarts if already running
def recompute(self):
... # the expensive work, once
Breakdown: setSingleShot(True) means the timer fires once rather than repeating. Calling start() on an already-running single-shot timer restarts it, which is what collapses a burst into one call — the handler itself does nothing but reset the clock, so it stays cheap enough to run thousands of times. Parenting the timer to the main window ties its lifetime to something that outlives the burst. Two hundred milliseconds is short enough to feel immediate and long enough to swallow a bulk edit. Remember to stop() and disconnect the timer in unload() alongside everything else.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | Identical signal set and signatures. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | QgsProject.layersWillBeRemoved added as a batch counterpart; the singular form remains. |
The project and vector-layer signals used here have been stable across the whole 3.x line.
Troubleshooting
- QGIS crashes when a layer is removed. A handler on
layersRemovedlooked the layer up and used it. Do that work inlayerWillBeRemovedinstead. - Every action happens twice after a reload. The previous instance's connections were never removed. Disconnect everything in
unload(). - A newly added layer is ignored. The plugin only wired the layers present at startup. Connect to
layersAddedand wire them as they arrive. - Only the first layer of a GeoPackage is handled.
layersAddedpasses a list; the handler treated it as a single layer. disconnect()raises TypeError. The connection is already gone, which is normal during teardown. Wrap it in atry/except TypeError.- The plugin shows stale data after opening a new project. Nothing is connected to
cleared. Reset caches there.
Conclusion
Signals keep a plugin honest about a project that changes underneath it. Connect to layersAdded and wire existing layers through the same function, do all layer-touching cleanup in layerWillBeRemoved while the object is still alive, reset caches on cleared, and disconnect every single connection in unload() — the last one is what makes the difference between a plugin that reloads cleanly and one that has to be debugged by restarting QGIS.
Frequently Asked Questions
What is the difference between layerWillBeRemoved and layersRemoved?layerWillBeRemoved fires before the layer is destroyed, so the object can still be resolved and safely disconnected. layersRemoved fires afterwards and carries only identifiers — looking those up or touching a cached wrapper crashes QGIS.
Why does my plugin react twice to everything after a reload?
The previous instance's signal connections are still attached. Disconnect every signal you connect in the matching unload(), wrapping each call in a try/except TypeError so it is safe when the connection has already gone.
How do I connect to layers the user adds later?
Connect to QgsProject.layersAdded and do the per-layer wiring there, then call the same function once with the layers already present. That way one code path handles both cases.
How do I know which layer emitted a signal?
Call self.sender() inside the slot. It returns the emitting object, which lets one handler serve every layer without creating a lambda per connection — lambdas would keep references alive and leak.
Is there a signal for the project being saved?
Yes, QgsProject.projectSaved, which fires after a successful write. It is the right place to clear a dirty flag your plugin maintains, and it does not fire when the save fails.