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.

The only safe moment to release a layer referenceA timeline of a layer being removed. layerWillBeRemoved fires first, while the object is still valid and can be safely inspected or disconnected. The layer is then destroyed. layersRemoved fires afterwards, carrying only identifiers, and touching the layer object from that handler dereferences freed memory.One of these hands you a live object; the other hands you a namelayer still validlayer destroyedlayerWillBeRemoved(layer)inspect it, disconnect from itdeletedC++ object gonelayersRemoved(ids)strings only — never look it up

Prerequisites

The signals worth knowing

QgsProject reports structural changes; a QgsVectorLayer reports changes to itself.

SignalEmitted byCarriesUse it for
layersAddedprojectlist of layerswiring up a newly loaded layer
layerWillBeRemovedprojectthe layerreleasing your reference safely
layersRemovedprojectlist of IDscleaning up keyed caches
clearedprojectnothingresetting when a new project opens
selectionChangedlayeradded / removed IDskeeping a panel in step
editingStarted / editingStoppedlayernothingenabling or disabling actions
attributeValueChangedlayerfid, index, valuelive validation
layerModifiedlayernothingmarking 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

What a missing disconnect does on reloadOn the left, a plugin that disconnects on unload leaves a single handler connected after a reload, and one signal produces one action. On the right, a plugin that does not disconnect leaves the previous instance's handler connected alongside the new one, so one signal reaches two handlers and the action runs twice.The old instance is gone from your code but not from the signaldisconnect() in unload()selectionChangedhandler v2the only oneone signal → one actionwhat you expectno disconnectselectionChangedhandler v1still attachedhandler v2the new oneone signal → two actionsand three after the next reload

    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.

Debouncing a burst of signalsA bulk edit emits a dense burst of attributeValueChanged signals. A naive handler recomputes on every one, freezing the interface for the duration. A debounced handler restarts a short single-shot timer on each signal and recomputes only once, after the burst has finished.One bulk edit, thousands of signalssignals emittednaive handlerrecomputes 30 times — UI frozendebouncedrecomputes once200 ms after the last signalthe timer restarts on every signal and only fires when the burst stops

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 versionPythonNotes
3.28 LTR3.9Identical signal set and signatures.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12QgsProject.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 layersRemoved looked the layer up and used it. Do that work in layerWillBeRemoved instead.
  • 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 layersAdded and wire them as they arrive.
  • Only the first layer of a GeoPackage is handled. layersAdded passes 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 a try/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.