Write a Processing Script with the @alg Decorator
A full QgsProcessingAlgorithm subclass needs a dozen methods before it does anything: name, displayName, group, groupId, createInstance, initAlgorithm, processAlgorithm and more. For a one-off tool that a team drops into the Processing Toolbox — reproject and clip to the project boundary, flag parcels below a minimum area, count features per category — that is more ceremony than logic. The @alg decorator writes the boilerplate for you: decorate a function, declare its inputs and outputs, and it becomes a script algorithm with a dialog, batch mode, model designer support and processing.run access.
This recipe belongs to Processing Provider Plugins for QGIS. It writes a decorated script, covers the common parameter types, writes features to a sink with progress and cancellation, and explains when to move to a full class.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series. The decorator has been available since 3.6.
- A task that fits in one function. The decorator suits scripts with a few inputs and outputs; see the last section for when to use a class.
- Familiarity with running algorithms from Python, as in running a Processing algorithm from a script.
A complete decorated script
Save a script like this in the Processing scripts folder — Processing Toolbox → Scripts → Create New Script opens an editor that saves there — and it appears in the Toolbox immediately.
from qgis.core import QgsFeatureSink, QgsProcessingException, QgsField, QgsFields
from qgis.PyQt.QtCore import QMetaType
from qgis.processing import alg
@alg(name="flag_small_parcels", label="Flag small parcels",
group="parcel_qa", group_label="Parcel quality checks")
@alg.input(type=alg.SOURCE, name="INPUT", label="Parcels", types=[alg.TYPE_VECTOR_POLYGON])
@alg.input(type=alg.NUMBER, name="MIN_AREA", label="Minimum area (m²)",
default=50.0, minValue=0.0)
@alg.input(type=alg.SINK, name="OUTPUT", label="Flagged parcels")
@alg.output(type=alg.NUMBER, name="FLAGGED", label="Number flagged")
def flag_small_parcels(instance, parameters, context, feedback, inputs):
"""
Copies every parcel and adds a small_parcel field that is true where the
area is below the minimum. Area is planar, in the layer's CRS units.
"""
source = instance.parameterAsSource(parameters, "INPUT", context)
if source is None:
raise QgsProcessingException(instance.invalidSourceError(parameters, "INPUT"))
min_area = instance.parameterAsDouble(parameters, "MIN_AREA", context)
fields = QgsFields(source.fields())
fields.append(QgsField("small_parcel", QMetaType.Type.Bool))
sink, dest_id = instance.parameterAsSink(
parameters, "OUTPUT", context, fields, source.wkbType(), source.sourceCrs())
if sink is None:
raise QgsProcessingException(instance.invalidSinkError(parameters, "OUTPUT"))
total = source.featureCount() or 1
flagged = 0
for i, feature in enumerate(source.getFeatures()):
if feedback.isCanceled():
break
is_small = feature.geometry().area() < min_area
flagged += int(is_small)
feature.setFields(fields, False)
feature.setAttributes(feature.attributes() + [is_small])
sink.addFeature(feature, QgsFeatureSink.FastInsert)
feedback.setProgress(100 * i / total)
feedback.pushInfo(f"{flagged} of {total} parcels below {min_area} m²")
return {"OUTPUT": dest_id, "FLAGGED": flagged}
Breakdown: Decorators are applied bottom-up, but read top-down they describe the algorithm: identity and group first, then inputs, then outputs. The function signature is fixed — instance is the generated algorithm object, which is where the parameterAs… helpers live. SOURCE accepts any vector layer or file and respects "selected features only"; types restricts it to polygons in the dialog. A SINK is an output the user chooses — a temporary layer, a file, a GeoPackage table — and parameterAsSink creates it with the fields, geometry type and CRS you give. The docstring becomes the help text shown beside the dialog. Returning a dictionary whose keys match the declared outputs is what makes FLAGGED available to models and to processing.run callers.
Parameter types you will use most
The decorator exposes the common Processing parameter types as constants on alg. Each maps to a widget in the dialog and a parameterAs… method in the function.
@alg(name="summarise_by_category", label="Summarise by category",
group="parcel_qa", group_label="Parcel quality checks")
@alg.input(type=alg.SOURCE, name="INPUT", label="Input layer")
@alg.input(type=alg.FIELD, name="CATEGORY", label="Category field",
parentLayerParameterName="INPUT")
@alg.input(type=alg.ENUM, name="STAT", label="Statistic",
options=["count", "total area", "mean area"], default=0)
@alg.input(type=alg.DISTANCE, name="TOLERANCE", label="Simplify tolerance",
default=0.0, parentParameterName="INPUT", optional=True)
@alg.input(type=alg.FILE_DEST, name="CSV", label="Summary CSV",
fileFilter="CSV files (*.csv)")
@alg.output(type=alg.FILE, name="CSV", label="Summary CSV")
def summarise_by_category(instance, parameters, context, feedback, inputs):
"""Writes one row per category with the chosen statistic."""
source = instance.parameterAsSource(parameters, "INPUT", context)
category = instance.parameterAsFields(parameters, "CATEGORY", context)[0]
stat = instance.parameterAsEnum(parameters, "STAT", context)
csv_path = instance.parameterAsFileOutput(parameters, "CSV", context)
totals = {}
for f in source.getFeatures():
if feedback.isCanceled():
break
key = f[category]
count, area = totals.get(key, (0, 0.0))
totals[key] = (count + 1, area + f.geometry().area())
with open(csv_path, "w", encoding="utf-8") as fh:
fh.write(f"{category},value\n")
for key, (count, area) in sorted(totals.items(), key=lambda kv: str(kv[0])):
value = [count, area, area / count][stat]
fh.write(f"{key},{value:.2f}\n")
return {"CSV": csv_path}
Breakdown: Save this as its own .py file with the same imports as the first script — each script file defines one decorated algorithm. parentLayerParameterName links the field combo to the chosen layer, so it lists that layer's fields. ENUM returns the index of the chosen option, which is why the statistic is picked by indexing a list. DISTANCE shows the linked layer's units next to the spin box, which prevents the classic metres-versus-degrees confusion. FILE_DEST produces a file path chosen in the dialog, and declaring a matching FILE output passes that path on to models. optional=True lets a parameter be left empty; read it with a check for None.
Run it, test it, and share it
Script algorithms live in the script provider, so their id is script: plus the name. That is enough to call them from code and to write a test.
import processing
from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry
parcels = QgsVectorLayer("Polygon?crs=EPSG:27700&field=id:integer", "test", "memory")
features = []
for i, size in enumerate((5, 20, 200)):
f = QgsFeature(parcels.fields())
f.setAttributes([i])
f.setGeometry(QgsGeometry.fromWkt(f"POLYGON((0 0,{size} 0,{size} {size},0 {size},0 0))"))
features.append(f)
parcels.dataProvider().addFeatures(features)
result = processing.run("script:flag_small_parcels", {
"INPUT": parcels, "MIN_AREA": 50, "OUTPUT": "TEMPORARY_OUTPUT",
})
assert result["FLAGGED"] == 2, result
print([f["small_parcel"] for f in result["OUTPUT"].getFeatures()])
Breakdown: A memory layer with three squares of known area is a complete test fixture: 25 m² and 400 m² are below and above the threshold in an obvious way, and 40,000 m² confirms nothing odd happens with large values. Asserting on the declared output number rather than on printed text makes the test robust to message wording. The same test runs under pytest with the approach in testing Processing algorithms with pytest-qgis. To share a script, copy the .py file into colleagues' Processing scripts folders, or point Processing → Options → Scripts folders at a shared network directory so everyone loads the same version.
When to write a class instead
The decorator generates a sensible default for everything it hides. Move to a QgsProcessingAlgorithm subclass, registered by a provider plugin, when you need any of the things it does not expose: validation across parameters in checkParameterValues, algorithm flags such as disabling background threads for code that touches the GUI, a custom provider with its own icon and grouping for a family of tools, translation of labels, or distribution through the plugin repository with versioned releases. The conversion is mechanical — the decorator arguments become method return values and the function body becomes processAlgorithm — and it is walked through in writing a custom Processing algorithm and registering a Processing provider in a plugin. For algorithms that transform features one at a time, a feature-based algorithm removes even more boilerplate.
QGIS version compatibility
The @alg decorator arrived in QGIS 3.6 and its parameter constants have stayed stable. QMetaType.Type for field types is required on the QGIS 4 series; on releases before 3.38 use QVariant.Bool. Processing runs algorithms in a background thread by default, so decorated scripts must not touch iface or widgets. Scripts saved on 3.x load on later versions as long as their Qt code uses scoped enums.
Troubleshooting
- The script does not appear in the Toolbox. It is not in a configured scripts folder, or it raised an error on import — check the Processing tab of the log.
- "Wrong number of decorators".
@algmust be the top decorator and every function needs one. - Output key missing in results. The returned dictionary key does not match an
@alg.outputname. - QGIS crashes when the script runs. The code touches GUI objects from the background thread.
- Field combo is empty.
parentLayerParameterNamedoes not match the source parameter's name.
Conclusion
Use @alg for focused tools: declare identity, inputs and outputs as decorators, read parameters through the instance, write features to a sink with progress and cancellation checks, and return every declared output. Test it with processing.run and a memory-layer fixture, share it through a scripts folder, and graduate to a class in a provider plugin when you need validation, flags, translation or releases.
Frequently Asked Questions
Can a decorated script call other algorithms?
Yes. Use processing.run(…, context=context, feedback=feedback, is_child_algorithm=True) so progress and temporary outputs are handled correctly.
Where is the scripts folder?
In the user profile under processing/scripts; Processing → Options shows the exact path and lets you add more.
Can the script load its output into the project?
Processing adds sink outputs automatically when run from the dialog. From code, use processing.runAndLoadResults.
How do I add an icon? Scripts use the generic script icon. Custom icons require a provider plugin.