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.

A function in, a full algorithm outOn the left, a Python function decorated with alg for name, label and group, plus alg.input decorators for a source, a distance and a sink, and an alg.output for a number. The decorator builds a QgsProcessingAlgorithm with name, displayName, group, initAlgorithm and processAlgorithm filled in. On the right, what users get: a Toolbox entry under Scripts, a generated dialog, batch mode, availability in the model designer, and the identifier script:flag_small_parcels for processing.run.Declare inputs and outputs, get the rest freeyour script@alg(name=…, label=…)@alg.input(SOURCE …)@alg.input(NUMBER …)@alg.input(SINK …)@alg.output(NUMBER …)def run(instance, parameters, context, feedback, inputs):what users get✓ Toolbox entry under Scripts✓ generated parameter dialog✓ batch processing mode✓ usable in the model designer✓ help panel from the docstringprocessing.run("script:flag_small_parcels", …)

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.

Declare the type, read it with the matching methodA table. SOURCE, a layer combo with selected-features option, read with parameterAsSource. RASTER_LAYER, a raster combo, parameterAsRasterLayer. NUMBER, a spin box, parameterAsDouble or parameterAsInt. DISTANCE, a spin box with units linked to a layer, parameterAsDouble. ENUM, a drop-down, parameterAsEnum. FIELD, a field combo linked to a layer, parameterAsFields. BOOL, a checkbox, parameterAsBool. SINK, an output layer chooser, parameterAsSink. FILE_DEST, a file save chooser, parameterAsFileOutput.alg type → dialog widget → readertypewidgetread withSOURCElayer + selected onlyparameterAsSourceRASTER_LAYERraster comboparameterAsRasterLayerNUMBER / DISTANCEspin box (with units)parameterAsDoubleENUMdrop-downparameterAsEnumFIELDfield combo from a layerparameterAsFieldsBOOL / STRINGcheckbox / text boxparameterAsBool / StringSINK / FILE_DESToutput chooserparameterAsSink / FileOutput

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

Script today, provider plugin tomorrowLeft column, stay with alg: one or two tools, fixed parameters, shared through a scripts folder, no translations needed. Right column, move to a QgsProcessingAlgorithm class in a provider plugin: custom checkParameterValues validation, flags such as no-threading, many related algorithms under one provider with an icon, translations, versioned releases through the plugin repository, and full unit testing as a package.The decorator is a starting point, not a ceiling@alg scriptone or two toolsfixed parametersshared scripts folderminutes to writeclass in a provider plugincustom validation, flagsmany algorithms, one providertranslations, releasesdistributable and versioned

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". @alg must be the top decorator and every function needs one.
  • Output key missing in results. The returned dictionary key does not match an @alg.output name.
  • QGIS crashes when the script runs. The code touches GUI objects from the background thread.
  • Field combo is empty. parentLayerParameterName does 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.