Add a Layer Tree Indicator in PyQGIS

The Layers panel already shows small icons beside layers: a pencil for layers in edit mode, a filter funnel for layers with a subset, a warning for layers with a CRS problem or missing data. These indicators tell users something important about a layer without them having to open anything. Plugins can add their own: a clock for data older than its refresh interval, a red mark for a layer that failed a validation rule, a lock for a layer under review, a cloud for data served from a remote source.

This recipe belongs to Extending QGIS with Custom Classes. It adds an indicator to a layer node, decides which layers get one, keeps indicators current as layers are added and changed, responds to clicks, and removes everything when the plugin unloads.

Indicators in the Layers panelA sketch of the Layers panel with four layers. Built-in indicators show a pencil for editing and a funnel for a filter. A plugin's indicator, a clock icon, marks one layer as stale with a tooltip giving the data's age. Clicking the indicator opens the plugin's refresh dialog.Small icons, important statusparcelseditbuildingsfiltertraffic counts!basemaptooltipdata is 9 days oldclick to refreshbuilt-in: edit · filter plugin: stale data

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series, with code running inside QGIS Desktop.
  • An icon file — SVG works best, since the panel scales icons for high-DPI screens. Plugins usually ship icons in their folder, as described in adding a plugin icon and resources.

Add an indicator to one layer

An indicator is a small object with an icon and a tooltip, attached to a node in a specific layer tree view.

from qgis.gui import QgsLayerTreeViewIndicator
from qgis.core import QgsProject
from qgis.PyQt.QtGui import QIcon
from qgis.utils import iface

view = iface.layerTreeView()
layer = QgsProject.instance().mapLayersByName("traffic_counts")[0]
node = QgsProject.instance().layerTreeRoot().findLayer(layer.id())

indicator = QgsLayerTreeViewIndicator(view)
indicator.setIcon(QIcon("/path/to/plugin/icons/stale.svg"))
indicator.setToolTip("Data is 9 days old (refresh interval: 7 days)")
view.addIndicator(node, indicator)

Breakdown: findLayer returns the layer's node in the project's layer tree; indicators are attached to nodes, not layers, so a layer that appears twice in the tree would need two. Passing the view as parent makes it own the indicator's lifetime on the Qt side. The tooltip is the indicator's real message — keep it specific: what is wrong and what to do about it. Indicators are view-specific; the main Layers panel is iface.layerTreeView().

Decide which layers get an indicator

Indicators should reflect a rule evaluated per layer. The example marks layers whose custom property records a last-refresh time older than their refresh interval.

A rule per layerFor each layer in the project, the plugin reads a custom property holding the last refresh time and an interval. If the data is older than the interval, the layer gets an indicator with a tooltip stating the age; otherwise any existing indicator is removed. The rule runs when layers are added, when the property changes, and on a timer.Evaluate, then add or removeeach layercustomPropertylast_refresholder thaninterval?yes: add / updatetooltip with ageno: removeif one exists

from datetime import datetime, timedelta

class StaleIndicators:
    def __init__(self, iface, icon_path):
        self.iface = iface
        self.view = iface.layerTreeView()
        self.icon = QIcon(icon_path)
        self.indicators = {}                     # layer id -> indicator

    def check_layer(self, layer):
        stamp = layer.customProperty("myplugin/last_refresh")
        days = int(layer.customProperty("myplugin/interval_days", 7))
        if not stamp:
            return self.remove(layer.id())
        age = datetime.now() - datetime.fromisoformat(stamp)
        if age > timedelta(days=days):
            self.add_or_update(layer, f"Data is {age.days} days old "
                                      f"(refresh interval: {days} days). Click to refresh.")
        else:
            self.remove(layer.id())

    def add_or_update(self, layer, text):
        node = QgsProject.instance().layerTreeRoot().findLayer(layer.id())
        if node is None:
            return
        ind = self.indicators.get(layer.id())
        if ind is None:
            ind = QgsLayerTreeViewIndicator(self.view)
            ind.setIcon(self.icon)
            ind.clicked.connect(lambda index, lid=layer.id(): self.on_click(lid))
            self.view.addIndicator(node, ind)
            self.indicators[layer.id()] = ind
        ind.setToolTip(text)

    def remove(self, layer_id):
        ind = self.indicators.pop(layer_id, None)
        node = QgsProject.instance().layerTreeRoot().findLayer(layer_id)
        if ind is not None and node is not None:
            self.view.removeIndicator(node, ind)

    def on_click(self, layer_id):
        self.iface.messageBar().pushInfo("Refresh", f"Refreshing layer {layer_id} …")

Breakdown: Storing the refresh time as a layer custom property keeps the state with the layer and in the project file, so it survives restarts. A dictionary from layer id to indicator lets the plugin update an existing indicator's tooltip instead of stacking duplicates — a common bug when the rule runs repeatedly. The clicked signal passes the model index of the node; capturing the layer id in the lambda's default argument binds each indicator to its own layer. In a real plugin, on_click would open a refresh dialog or start a background task.

Keep indicators current

Rules must run when the situation changes: when layers are added or loaded with a project, when the relevant property changes, and periodically for time-based rules.

from qgis.PyQt.QtCore import QTimer

class StaleIndicators(StaleIndicators):
    def start(self):
        project = QgsProject.instance()
        project.layersAdded.connect(self._check_many)
        project.readProject.connect(lambda *_: self._check_all())
        project.layerWillBeRemoved.connect(self.remove)
        self.timer = QTimer()
        self.timer.timeout.connect(self._check_all)
        self.timer.start(60 * 60 * 1000)          # hourly
        self._check_all()

    def _check_many(self, layers):
        for lyr in layers:
            self.check_layer(lyr)

    def _check_all(self):
        for lyr in QgsProject.instance().mapLayers().values():
            self.check_layer(lyr)

    def stop(self):
        project = QgsProject.instance()
        for sig, slot in ((project.layersAdded, self._check_many),
                          (project.layerWillBeRemoved, self.remove)):
            try:
                sig.disconnect(slot)
            except TypeError:
                pass
        self.timer.stop()
        for lid in list(self.indicators):
            self.remove(lid)

Breakdown: layersAdded covers layers added by users or scripts; readProject covers opening a project, when indicators from the previous project are gone and new layers need checking. Removing the indicator when a layer is about to be removed avoids holding references to nodes that no longer exist. An hourly timer re-evaluates time-based rules without user action. stop disconnects signals and removes every indicator — essential in unload(), or a reloaded plugin will add a second set alongside the first, as discussed in reloading a plugin without restarting.

Flag layers that fail validation

Staleness is one rule; data quality is another common one. An indicator that appears on layers failing a quick validation — invalid geometries, missing required values — puts quality problems in front of users the moment a layer is loaded, rather than when an analysis goes wrong.

from qgis.core import QgsVectorLayer, QgsFeatureRequest

def quick_issues(layer, required=("asset_id",), sample=2000):
    if not isinstance(layer, QgsVectorLayer) or not layer.isSpatial():
        return []
    req = QgsFeatureRequest().setLimit(sample)
    invalid = missing = 0
    names = [n for n in required if n in layer.fields().names()]
    for f in layer.getFeatures(req):
        if f.hasGeometry() and not f.geometry().isGeosValid():
            invalid += 1
        if any(f[n] is None or (hasattr(f[n], "isNull") and f[n].isNull()) for n in names):
            missing += 1
    issues = []
    if invalid:
        issues.append(f"{invalid} invalid geometries")
    if missing:
        issues.append(f"{missing} features missing {', '.join(names)}")
    return issues

class QualityIndicators(StaleIndicators):
    def check_layer(self, layer):
        issues = quick_issues(layer)
        if issues:
            self.add_or_update(layer, "Quality check (first 2,000 features): "
                                      + "; ".join(issues) + ". Click for the full report.")
        else:
            self.remove(layer.id())

Breakdown: Checking a limited sample keeps the rule fast enough to run whenever a layer is added; the tooltip says it is a sample so users do not mistake it for a full audit. Subclassing the stale-data manager reuses all of its bookkeeping — the dictionary, signal connections and cleanup — and replaces only the rule. Clicking can launch the full data quality report as a background task. Running the geometry check on very large or remote layers still costs time, so restrict this rule to local file formats if your users work with big PostGIS tables.

Wire it into the plugin

The plugin creates the manager in initGui, starts it, and stops it in unload.

import os

class StaleDataPlugin:
    def __init__(self, iface):
        self.iface = iface
        self.indicators = None

    def initGui(self):
        icon = os.path.join(os.path.dirname(__file__), "icons", "stale.svg")
        self.indicators = StaleIndicators(self.iface, icon)
        self.indicators.start()

    def unload(self):
        if self.indicators:
            self.indicators.stop()
            self.indicators = None

Breakdown: Resolving the icon path relative to the plugin file keeps it working wherever the plugin is installed. Everything the plugin added is removed in unload, leaving the Layers panel exactly as it was. That symmetry — every connect has a disconnect, every add has a remove — is the most important habit in plugins that extend QGIS's interface.

Design indicators users understand

Indicators compete for a small space. A few rules keep them useful.

Indicator design rulesUse a distinct, simple icon that does not imitate built-in indicators. Put the explanation and the action in the tooltip. Show an indicator only for exceptional states, never for the normal case, so its presence means something. Make clicking do the obvious thing, such as opening the fix.Rare, clear, actionabledistinctsimple iconnot a built-inexplainedtooltip sayswhat and whyexceptionalonly whensomething's wrongactionableclick opensthe fix

Show indicators for exceptions, never for the normal state — an icon on every layer stops meaning anything. Avoid icons that resemble QGIS's own indicators, so users do not confuse a plugin's warning with a CRS problem. Put the reason and the remedy in the tooltip. And make the click do the obvious thing: open the dialog that resolves the issue, or the documentation that explains it.

QGIS version compatibility

QgsLayerTreeViewIndicator and addIndicator/removeIndicator exist since QGIS 3.2 and work unchanged on 3.34 LTR, 3.40 LTR and QGIS 4. On QGIS 4, QTimer and other Qt classes come from PyQt6 through qgis.PyQt with no code changes for this example.

Troubleshooting

  • Indicators multiply. The rule adds a new indicator each run; keep a dictionary and update existing ones.
  • Indicators vanish after opening a project. The new project's layers were never checked; connect to readProject.
  • Clicks hit the wrong layer. The lambda captured a loop variable by reference; bind it with a default argument.
  • Indicators remain after the plugin unloads. They were not removed in unload().

Conclusion

Attach indicators to layer tree nodes with QgsLayerTreeViewIndicator, evaluate a clear rule per layer and update rather than duplicate, re-run the rule on layer, project and timer events, make clicks open the fix, and remove every indicator and connection when the plugin unloads.

Frequently Asked Questions

Can indicators be added to groups? Yes; attach them to group nodes the same way.

Do indicators show in the layer tree of a print layout legend? No. They are part of the Layers panel view only.

Can I change the icon dynamically? Yes; call setIcon on the existing indicator, for example to show severity levels.

Are indicators saved in the project? No. Store the underlying state, such as a custom property, and recompute indicators on load.