Create Layer Actions in PyQGIS
A layer action is a small command attached to a layer that runs against one feature: open the asset's page in the maintenance system, zoom to every parcel with the same owner, copy a formatted grid reference to the clipboard, email the inspection record. Users run it from the Run Feature Action tool on the map, from a button column in the attribute table, or from the feature form. It is the lightest way to connect a map to the rest of an organisation's systems — no plugin to install, because the action is stored with the layer.
This recipe belongs to Attribute Forms & Layer Actions in PyQGIS. It builds URL and Python actions from code, explains how attribute values are substituted into them, controls where each action appears, and runs actions from scripts.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series.
- A vector layer with an attribute that identifies each feature in some other system, if you want URL actions.
- An understanding of QGIS expressions, because action text is expanded with them.
A URL action built from attributes
The simplest useful action opens a web page for the feature. The action text contains expression placeholders — an expression between [% and %] — which QGIS evaluates against the chosen feature before opening the result.
from qgis.core import Qgis, QgsProject, QgsAction
assets = QgsProject.instance().mapLayersByName("street_trees")[0]
open_record = QgsAction(
Qgis.AttributeActionType.OpenUrl,
"Open asset record",
"https://assets.example.gov/trees/[% url_encode(map('id', \"asset_id\")) %]",
"",
False,
"Record",
{"Feature", "Canvas"},
)
assets.actions().addAction(open_record)
print(open_record.id())
Breakdown: The constructor takes the action type, a description shown in menus, the action text, an icon path, a capture flag, a short title used on buttons, and the set of scopes where the action appears. url_encode(map(…)) produces a correctly escaped id=TR-10432 query string; for a value inserted into a path segment, url_encode is not needed when ids contain only safe characters, but any user-entered text — a street name with an ampersand — needs escaping or the link breaks. The action gets a generated id, which is how it is removed or run later. Nothing needs saving explicitly: actions are part of the layer, stored in the project and in the layer's QML style.
A Python action
Python actions run inside QGIS with the same placeholders expanded first. That makes them powerful — anything PyQGIS can do, an action can do to the clicked feature — and it is also why the substituted values must never be pasted into code unquoted.
SELECT_SAME_OWNER = """
from qgis.core import QgsProject, QgsFeatureRequest, QgsExpression
from qgis.utils import iface
layer = QgsProject.instance().mapLayer('[% @layer_id %]')
feature = layer.getFeature([% $id %])
owner = feature['owner_ref']
expr = f'"owner_ref" = {QgsExpression.quotedValue(owner)}'
ids = [f.id() for f in layer.getFeatures(
QgsFeatureRequest().setFilterExpression(expr).setNoAttributes())]
layer.selectByIds(ids)
iface.mapCanvas().zoomToSelected(layer)
iface.messageBar().pushInfo('Same owner', f'{len(ids)} parcels selected')
"""
select_same_owner = QgsAction(
Qgis.AttributeActionType.GenericPython,
"Select all parcels with this owner",
SELECT_SAME_OWNER,
"",
False,
"Same owner",
{"Feature", "Canvas"},
)
parcels = QgsProject.instance().mapLayersByName("parcels")[0]
parcels.actions().addAction(select_same_owner)
Breakdown: Only two placeholders are substituted: @layer_id, an internal identifier, and $id, an integer. Everything else is read from the feature inside the code, so an owner reference containing quotes, newlines or anything hostile stays a Python string. QgsExpression.quotedValue then quotes it safely for the filter expression. setNoAttributes() keeps the selection query fast on a large layer, as in speeding up feature iteration. The message bar gives feedback without a modal dialog, the pattern from showing messages with QgsMessageBar.
Scopes: where an action appears
An action's scopes decide which user interfaces offer it. The same action can appear in several places, and a well-chosen set keeps menus short.
copy_value = QgsAction(
Qgis.AttributeActionType.GenericPython,
"Copy value to clipboard",
"from qgis.PyQt.QtWidgets import QApplication\n"
"from qgis.core import QgsProject\n"
"lyr = QgsProject.instance().mapLayer('[% @layer_id %]')\n"
"value = lyr.getFeature([% $id %])[[% @field_index %]]\n"
"QApplication.clipboard().setText('' if value is None else str(value))",
"",
False,
"Copy",
{"Field"},
)
export_selection = QgsAction(
Qgis.AttributeActionType.GenericPython,
"Export selected features to the review folder",
"import processing\n"
"from qgis.core import QgsProject, QgsProcessingFeatureSourceDefinition\n"
"lyr = QgsProject.instance().mapLayer('[% @layer_id %]')\n"
"processing.run('native:savefeatures', {"
"'INPUT': QgsProcessingFeatureSourceDefinition(lyr.id(), selectedFeaturesOnly=True),"
"'OUTPUT': '/srv/review/' + lyr.name() + '.gpkg'})",
"",
False,
"Export selection",
{"Layer"},
)
for action in (copy_value, export_selection):
assets.actions().addAction(action)
table_config = assets.attributeTableConfig()
table_config.setActionWidgetVisible(True)
table_config.setActionWidgetStyle(table_config.ButtonList)
assets.setAttributeTableConfig(table_config)
Breakdown: The Field scope adds @field_name, @field_value and @field_index to the expression context, so one action serves every column. It would be tempting to substitute @field_value straight into the code, but a placeholder is replaced by the bare text of the value, not a quoted Python literal — the same trap as before. Substituting the integer field index and the feature id, then reading the value inside the code, copies any value correctly, including ones with quotes and line breaks. The Layer scope runs without a feature and suits bulk operations on the current selection. Making the action widget visible in the attribute table config adds a column of buttons, one per feature-scoped action, which is the most discoverable place for them.
Run actions from a script
Actions are not only for clicking. A script can list a layer's actions and run one against a feature with a proper expression context — useful for testing an action, or for reusing logic already maintained as an action.
from qgis.core import QgsExpressionContext, QgsExpressionContextUtils
layer = parcels
manager = layer.actions()
by_name = {a.name(): a for a in manager.actions()}
action = by_name["Select all parcels with this owner"]
feature = next(layer.getFeatures())
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
context.setFeature(feature)
manager.doAction(action.id(), feature, context)
for a in manager.actions():
print(f"{a.shortTitle() or a.name():<20} {a.type()} scopes={sorted(a.actionScopes())}")
Breakdown: globalProjectLayerScopes supplies the same variables the user interface provides — project and layer variables, including @layer_id — so placeholders expand identically. doAction evaluates the placeholders and runs the action synchronously; a Python action that shows a message bar needs QGIS Desktop, so guard such actions if the script might run headless. Listing actions with their scopes is also a quick audit of what a shared project will let users run.
Replace actions idempotently and ship them with the data
A script that adds actions will be run again — after a fix to the Python, after a URL changes — and addAction does not check for duplicates. Remove any previous version by name first, then save the actions into the data source so users who add the layer from the file get them too.
from qgis.core import QgsMapLayer
def install_actions(layer, actions):
manager = layer.actions()
wanted = {a.name() for a in actions}
for existing in manager.actions():
if existing.name() in wanted:
manager.removeAction(existing.id())
for action in actions:
manager.addAction(action)
error = layer.saveStyleToDatabase(
"actions", "layer actions", False, "",
QgsMapLayer.StyleCategory.Actions,
)
return error
err = install_actions(parcels, [select_same_owner, export_selection])
print("saved" if not err else err)
Breakdown: Matching on the action name makes the install repeatable: the second run replaces rather than duplicates, and actions added by hand under other names are left alone. Saving with only the Actions category, and not as the default style, stores the actions alongside any existing default style instead of replacing it; users can load them from the layer's style menu, or a plugin can apply them on load. Where the actions are part of the layer's standard configuration, save them into the default style together with the Forms category so a single style carries both. Keeping install_actions in a module next to the scripts that build forms means one command configures a layer completely.
QGIS version compatibility
Qgis.AttributeActionType is the 3.36+ spelling; earlier releases use QgsAction.GenericPython and QgsAction.OpenUrl, which the QGIS 4 series no longer accepts. The seven-argument QgsAction constructor with scopes has been available since 3.0. setActionWidgetStyle takes QgsAttributeTableConfig.ActionWidgetStyle.ButtonList in its scoped form on QGIS 4. Mobile apps do not run Python actions; URL actions generally work.
Troubleshooting
- The action does not appear on the map. It lacks the
Canvasscope, or the Run Feature Action tool is not selected. - Placeholders appear literally in the URL. The expression inside
[% %]has a syntax error; test it in the expression builder. - A Python action fails for some features only. A substituted text value contains a quote; substitute the feature id instead.
- Actions disappear when the layer is loaded in another project. Save the layer style with the
Actionscategory to the data source. - Nothing happens in QGIS Server or a script. Python actions that use
ifaceneed QGIS Desktop.
Conclusion
Use URL actions to link features to other systems and Python actions for anything that must act inside QGIS. Substitute only identifiers into Python action text and read everything else inside the code. Choose scopes deliberately, show feature actions as buttons in the attribute table, and save actions with the layer's style so they follow the data.
Frequently Asked Questions
Can an action ask the user for input?
Yes, a Python action can open a QInputDialog. Keep the prompt short, since actions are meant to be quick.
Can I trigger an action automatically when a feature is added?
No — actions are user-invoked. React to featureAdded on the layer from a plugin or form code instead.
How is an action different from a plugin? An action lives with one layer and needs nothing installed; a plugin lives with the user's QGIS and can serve every layer. Graduate an action to a plugin once several layers or projects need it.
Is it safe to open projects containing actions from others? Python actions only run when a user triggers them, but they can do anything once triggered. Review actions in untrusted projects before running them.