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.
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.
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.
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.