Add a Layer Context Menu Action in PyQGIS

A toolbar button is the default home for a plugin command, and often the wrong one. Commands that act on a layer — export it to the corporate format, check its schema, publish it — belong where the user already has the layer in hand: its right-click menu in the Layers panel. Commands that act on features — open the asset record, create a work order from these three pipes — belong in the feature menus of the attribute table and the identify results. QGIS provides extension points for both, and for small status icons beside layer names.

This recipe belongs to Custom Map Tools and Canvas Interaction. It adds a layer-type action to the Layers panel context menu, restricts actions to specific layers, registers feature actions with QgsMapLayerAction, adds a layer tree indicator, and removes everything on unload.

Where layer and feature commands liveLeft: a Layers panel with a right-click menu on the layer water_mains showing standard entries and a plugin submenu with Export to asset system and Check schema, added with addCustomActionForLayerType. Middle: an attribute table row with an actions menu entry Create work order, added with QgsMapLayerAction and the map layer action registry. Right: a layer name with a small warning icon indicator whose tooltip says schema check failed, added with a layer tree view indicator.Put the command next to the thing it acts onLayers panel menuwater_mainsZoom to LayerOpen Attribute TableAsset tools ▸Export to asset systemCheck schemafeature menusid material dn41 PE 180Create work orderOpen asset recordattribute table, identifyindicatorswater_mains!schema check failedvalvesstatus beside the name

Prerequisites

  • QGIS 3.40 LTR or newer, or the QGIS 4 series.
  • A plugin with initGui and unload, as described in QGIS plugin boilerplate and structure. The console works for experiments, but actions added there must be removed by hand.

Add an action to every layer of a type

iface.addCustomActionForLayerType adds a QAction to the Layers panel context menu for all layers of one type, optionally inside a named submenu.

from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction
from qgis.core import Qgis, QgsMapLayer


class AssetToolsPlugin:
    MENU = "Asset tools"

    def __init__(self, iface):
        self.iface = iface
        self.layer_actions = []

    def initGui(self):
        export = QAction(QIcon(":/plugins/asset_tools/export.svg"),
                         "Export to asset system", self.iface.mainWindow())
        export.triggered.connect(self.export_current_layer)

        check = QAction("Check schema", self.iface.mainWindow())
        check.triggered.connect(self.check_current_layer)

        for action in (export, check):
            self.iface.addCustomActionForLayerType(
                action, self.MENU, Qgis.LayerType.Vector, True)
            self.layer_actions.append(action)

    def export_current_layer(self):
        layer = self.iface.layerTreeView().currentLayer()
        if layer is None:
            return
        self.iface.messageBar().pushInfo("Asset tools", f"Exporting {layer.name()} …")

    def check_current_layer(self):
        layer = self.iface.layerTreeView().currentLayer()
        ...

    def unload(self):
        for action in self.layer_actions:
            self.iface.removeCustomActionForLayerType(action)
        self.layer_actions.clear()

Breakdown: The second argument names a submenu; passing an empty string puts the action at the top level of the menu, which is best reserved for a single, very common command. The last argument, allLayers=True, shows the action for every vector layer. Right-clicking a layer makes it the current layer in the tree view, so currentLayer() inside the handler is the layer the user clicked — the handler receives no layer argument of its own. removeCustomActionForLayerType in unload is essential: without it, reloading the plugin during development adds a second copy of every entry, and uninstalling leaves dead actions behind until QGIS restarts.

Show an action only for suitable layers

Many commands only make sense for some layers — the export only for layers that have the asset schema. Passing allLayers=False registers the action for no layers, and addCustomActionForLayer then enables it layer by layer.

Every layer, or only the right onesLeft: with allLayers true, the Export to asset system action appears on water_mains, valves, parcels and a basemap, including layers where it would fail. Right: with allLayers false, the plugin checks each layer's fields when it is added to the project and calls addCustomActionForLayer only for water_mains and valves, which have an asset_id field. The action simply does not appear on parcels or the basemap.Hide commands that cannot work on a layerallLayers = Truewater_mains✓ shownvalves✓ shownparcelsshown, failsroads_osmshown, failsallLayers = False + per layerwater_mains✓ shownvalves✓ shownparcelsnot offeredroads_osmnot offered

from qgis.core import QgsProject

class AssetToolsPlugin(AssetToolsPlugin):
    def initGui(self):
        self.export = QAction("Export to asset system", self.iface.mainWindow())
        self.export.triggered.connect(self.export_current_layer)
        self.iface.addCustomActionForLayerType(self.export, self.MENU, Qgis.LayerType.Vector, False)

        project = QgsProject.instance()
        for layer in project.mapLayers().values():
            self._maybe_enable(layer)
        project.layerWasAdded.connect(self._maybe_enable)

    def _maybe_enable(self, layer):
        if layer.type() == Qgis.LayerType.Vector and "asset_id" in layer.fields().names():
            self.iface.addCustomActionForLayer(self.export, layer)

    def unload(self):
        QgsProject.instance().layerWasAdded.disconnect(self._maybe_enable)
        self.iface.removeCustomActionForLayerType(self.export)

Breakdown: Registering with allLayers=False tells QGIS the action exists but belongs to no layer yet. Checking existing layers at start-up and every new layer through layerWasAdded keeps the menu accurate as projects change. The test here is a field name; checking provider type, geometry type, a custom layer property or a naming convention works the same way. Disconnecting the signal in unload avoids calls into a plugin that is no longer loaded — the signal pattern is covered in connecting to layer and project signals. The subclass here only separates the example from the previous one; in a real plugin, this is the whole class.

Feature actions in the attribute table and identify results

For commands on features, register a QgsMapLayerAction. It appears in the attribute table's actions column and toolbar, in the identify results' feature menu, and in feature forms, and it receives the features directly.

from qgis.gui import QgsGui, QgsMapLayerAction

class WorkOrders:
    def __init__(self, iface):
        self.iface = iface
        self.action = None

    def initGui(self):
        self.action = QgsMapLayerAction(
            "Create work order", self.iface.mainWindow(),
            Qgis.LayerType.Vector,
            Qgis.MapLayerActionTarget.SingleFeature | Qgis.MapLayerActionTarget.MultipleFeatures,
        )
        self.action.triggeredForFeaturesV2.connect(self.create_work_order)
        QgsGui.mapLayerActionRegistry().addMapLayerAction(self.action)

    def create_work_order(self, layer, features, context):
        ids = [f["asset_id"] for f in features]
        self.iface.messageBar().pushSuccess(
            "Work orders", f"Draft order created for {len(ids)} assets from {layer.name()}")

    def unload(self):
        if self.action:
            QgsGui.mapLayerActionRegistry().removeMapLayerAction(self.action)
            self.action = None

Breakdown: Targets declare what the action works on: a single feature, several selected features, or the layer. Declaring both single and multiple lets users run it on one row or a selection. The V2 signals pass a context object alongside the layer and features, which carries the originating widget; the older signals without context still work but are deprecated. Unlike the layer-tree actions, these appear wherever QGIS offers feature actions, so there is one registration for several interfaces. For actions that belong to one layer's data rather than a plugin, the lighter alternative is creating layer actions, which are stored with the layer.

A status icon beside the layer nameThe Layers panel shows three layers. water_mains carries an orange warning indicator with tooltip schema check failed: missing field dn. Clicking the indicator emits clicked, which the plugin uses to open a report. valves carries a green tick indicator. parcels has none. The plugin keeps a dictionary from layer id to indicator so it can remove or replace indicators when a check is re-run.Indicators show state without opening anythingLayerswater_mains!valvesparcelsper indicatorsetIcon · setToolTipclicked → open reportview.addIndicator(node, ind)view.removeIndicator(node, ind)

Show status with a layer tree indicator

Indicators are small clickable icons beside a layer's name — QGIS uses them for filters, embedded layers and invalid sources. A plugin can add its own to surface the result of a check without opening a dialog.

from qgis.gui import QgsLayerTreeViewIndicator

class SchemaIndicators:
    def __init__(self, iface):
        self.iface = iface
        self.indicators = {}

    def mark(self, layer, ok, message):
        view = self.iface.layerTreeView()
        node = QgsProject.instance().layerTreeRoot().findLayer(layer.id())
        if node is None:
            return
        self.clear(layer)
        indicator = QgsLayerTreeViewIndicator(view)
        indicator.setIcon(QIcon(":/images/themes/default/mIconSuccess.svg" if ok
                                else ":/images/themes/default/mIconWarning.svg"))
        indicator.setToolTip(message)
        indicator.clicked.connect(lambda _index, lyr=layer: self.show_report(lyr))
        view.addIndicator(node, indicator)
        self.indicators[layer.id()] = (node, indicator)

    def clear(self, layer):
        entry = self.indicators.pop(layer.id(), None)
        if entry:
            node, indicator = entry
            self.iface.layerTreeView().removeIndicator(node, indicator)

    def show_report(self, layer):
        entry = self.indicators.get(layer.id())
        if entry:
            _node, indicator = entry
            self.iface.messageBar().pushInfo(f"Schema check: {layer.name()}", indicator.toolTip())

    def unload(self):
        for layer_id in list(self.indicators):
            node, indicator = self.indicators.pop(layer_id)
            self.iface.layerTreeView().removeIndicator(node, indicator)

Breakdown: Indicators attach to layer tree nodes, not layers, so findLayer looks up the node for a layer id. Keeping a dictionary of the indicators the plugin added makes it possible to replace one when a check is re-run and to remove all of them on unload — indicators are not removed automatically. QGIS's built-in theme icons are available through :/images/themes/default/… resource paths, which gives indicators a native look without shipping icons. The click handler binds the layer as a default argument so each indicator remembers its own layer.

Use indicators sparingly. The Layers panel is where users manage everything else in the project, and a plugin that decorates every layer with an icon quickly turns the panel into noise. The good cases are states that need attention and that the user would otherwise have to go looking for: a failed validation, a layer that is out of date relative to its source, a layer that is locked for editing by another user. Pair each indicator with a tooltip that says what is wrong in plain language and a click action that leads to the fix, and clear it as soon as the state resolves.

QGIS version compatibility

addCustomActionForLayerType, addCustomActionForLayer and layer tree indicators have been available since QGIS 3.0 and 3.2. Qgis.MapLayerActionTarget and the triggeredFor…V2 signals with a context argument arrived in 3.30; earlier releases use QgsMapLayerAction.SingleFeature and the signals without context. Qgis.LayerType.Vector replaced QgsMapLayer.VectorLayer in 3.30. The QGIS 4 series accepts only the scoped enums and the V2 signals.

Troubleshooting

  • Entries appear twice after reloading the plugin. They were not removed in unload.
  • The action appears but acts on the wrong layer. The handler used activeLayer() after the selection changed; read currentLayer() at trigger time.
  • A feature action is missing from the attribute table. Its targets do not include the kind of use, or the layer type does not match.
  • Indicators linger after the plugin is disabled. They were not tracked and removed.
  • Per-layer actions vanish when a project is reopened. Layers are new objects; re-run the check on layerWasAdded.

Conclusion

Add layer commands to the Layers panel context menu with addCustomActionForLayerType, grouped in a submenu and restricted with per-layer registration where they only apply to some layers. Register feature commands once with QgsMapLayerAction so they appear in the attribute table, identify results and forms. Use indicators for status, and remove every action and indicator in unload.

Frequently Asked Questions

Can I add actions to group nodes in the Layers panel? The custom layer actions target layers. For groups, connect to the layer tree view's context menu mechanism or provide the command elsewhere.

Can a context menu action show a submenu of choices? Yes. Give the QAction a QMenu with its own actions via setMenu.

Do these actions work for raster layers? Yes — pass Qgis.LayerType.Raster as the layer type.

How do I know which features were selected when a MultipleFeatures action runs? They are passed to the signal; you do not need to read the selection yourself.