Write a Feature-Based Processing Algorithm in PyQGIS

A large share of Processing algorithms do the same thing: read each feature, change it — its geometry, an attribute, both — and write it out. Buffer, centroid, add a field, round coordinates, clean text. Writing those as a full QgsProcessingAlgorithm means repeating the same source, sink, loop, progress and cancellation code every time. QgsProcessingFeatureBasedAlgorithm owns all of that. You implement one method that transforms a single feature and a few methods that describe the output, and you get the input and output parameters, the loop, progress, cancellation, selected-features support and — if you opt in — the ability to run in place on an editable layer.

This recipe belongs to Processing Provider Plugins for QGIS. It writes a feature-based algorithm that normalises text attributes and one that transforms geometry, declares output fields and geometry types correctly, adds parameters, and enables in-place editing.

You write the middle, the base class writes the loopA pipeline from an input source to an output sink. The base class owns declaring INPUT and OUTPUT parameters, preparing the source, creating the sink with the fields from outputFields and the type from outputWkbType, iterating features, progress, cancellation and selected-features-only. In the middle, your processFeature receives one feature and returns a list of zero, one or several features. Optional hooks are initParameters, prepareAlgorithm and supportInPlaceEdit.One feature at a time, everything else inheritedINPUTsource, selection,iteration, progressbase classprocessFeature(feature, …)change geometry or attributesreturn [] · [f] · [f1, f2, …]you write thisOUTPUTsink created fromyour field/type hooksbase classoptional hooksinitParameters · prepareAlgorithm · outputFields · outputWkbType · supportInPlaceEdit

Prerequisites

  • QGIS 3.40 LTR or newer, or the QGIS 4 series.
  • A provider to register the algorithm with, as in registering a Processing provider in a plugin. For experiments, a provider in the Python console is enough.
  • A transformation that works on one feature at a time. Anything that needs to see several features together — dissolving, clustering, nearest neighbours — belongs in a normal algorithm.

An attribute transformation

This algorithm trims whitespace, collapses repeated spaces and applies consistent capitalisation to chosen text fields. The feature passes through with its geometry untouched.

import re
from qgis.core import (
    QgsProcessingFeatureBasedAlgorithm, QgsProcessingParameterField,
    QgsProcessingParameterEnum, QgsProcessing,
)

class NormaliseText(QgsProcessingFeatureBasedAlgorithm):
    FIELDS = "FIELDS"
    CASE = "CASE"
    CASES = ["Leave case", "UPPER", "lower", "Title Case"]

    def name(self): return "normalise_text"
    def displayName(self): return "Normalise text fields"
    def group(self): return "Data cleaning"
    def groupId(self): return "data_cleaning"
    def outputName(self): return "Normalised"
    def createInstance(self): return NormaliseText()
    def inputLayerTypes(self): return [QgsProcessing.TypeVector]

    def initParameters(self, config=None):
        self.addParameter(QgsProcessingParameterField(
            self.FIELDS, "Text fields to normalise", parentLayerParameterName="INPUT",
            type=QgsProcessingParameterField.String, allowMultiple=True))
        self.addParameter(QgsProcessingParameterEnum(
            self.CASE, "Capitalisation", options=self.CASES, defaultValue=0))

    def prepareAlgorithm(self, parameters, context, feedback):
        self.field_names = self.parameterAsFields(parameters, self.FIELDS, context)
        self.case = self.parameterAsEnum(parameters, self.CASE, context)
        return True

    def processFeature(self, feature, context, feedback):
        for name in self.field_names:
            value = feature[name]
            if not isinstance(value, str):
                continue
            value = re.sub(r"\s+", " ", value).strip()
            value = [value, value.upper(), value.lower(), value.title()][self.case]
            feature[name] = value
        return [feature]

Breakdown: initParameters adds parameters beyond the inherited INPUT and OUTPUT; the field parameter links to INPUT by name, which the base class creates. prepareAlgorithm runs once before the loop, so parameter parsing happens once, not per feature — storing the results on self is safe because every run gets a fresh instance from createInstance. processFeature receives each feature and returns a list: here always one feature, modified in place. Non-string values, including nulls, are skipped rather than converted, so numbers and empty cells survive untouched. Because output fields and geometry type are unchanged, no other methods are needed.

A geometry transformation with a new field

When the algorithm changes the geometry type or adds fields, it must say so, because the base class creates the output sink before it sees any feature.

Describe the output before producing itAn input layer of MultiPolygon features with fields id and name. outputWkbType maps MultiPolygon to Point, because the algorithm produces label points. outputFields appends inner_distance as a double. The sink is created with Point geometry and fields id, name and inner_distance. Every feature returned from processFeature must have a point geometry and exactly those three attributes, or the sink rejects it.The sink is built from your hooks, not from featuresinputMultiPolygonid, nameoutputWkbTypeMultiPolygon → PointoutputFields+ inner_distanceoutput sinkPointid, name, inner_distancefeatures that do not match the declared schema are rejected by the sink

from qgis.core import (
    QgsField, QgsFields, QgsWkbTypes, QgsProcessingParameterDistance,
    QgsProcessingException, QgsFeatureSink,
)
from qgis.PyQt.QtCore import QMetaType

class InteriorLabelPoints(QgsProcessingFeatureBasedAlgorithm):
    TOLERANCE = "TOLERANCE"

    def name(self): return "interior_label_points"
    def displayName(self): return "Interior label points"
    def group(self): return "Cartography helpers"
    def groupId(self): return "carto_helpers"
    def outputName(self): return "Label points"
    def createInstance(self): return InteriorLabelPoints()
    def inputLayerTypes(self): return [QgsProcessing.TypeVectorPolygon]

    def initParameters(self, config=None):
        self.addParameter(QgsProcessingParameterDistance(
            self.TOLERANCE, "Precision", defaultValue=1.0, parentParameterName="INPUT",
            minValue=0.001))

    def outputWkbType(self, input_wkb_type):
        return QgsWkbTypes.Point

    def outputFields(self, input_fields):
        fields = QgsFields(input_fields)
        fields.append(QgsField("inner_distance", QMetaType.Type.Double))
        return fields

    def prepareAlgorithm(self, parameters, context, feedback):
        self.tolerance = self.parameterAsDouble(parameters, self.TOLERANCE, context)
        return True

    def processFeature(self, feature, context, feedback):
        geom = feature.geometry()
        if geom.isEmpty():
            feedback.reportError(f"feature {feature.id()} has no geometry", False)
            return []
        point = geom.poleOfInaccessibility(self.tolerance)
        label_point, distance = point
        feature.setGeometry(label_point)
        feature.setAttributes(feature.attributes() + [distance])
        return [feature]

Breakdown: outputWkbType receives the input type and returns the output type; returning a fixed Point is right here because every polygon becomes one point. outputFields receives the input fields and returns the output fields; appending keeps the original attributes in order. The pole of inaccessibility is the point inside a polygon farthest from its boundary — a better label anchor than the centroid, which can fall outside a crescent-shaped polygon — and the method returns both the point and that distance, which is stored as a handy "how much room for a label" measure. Returning an empty list drops a feature from the output; reportError with False logs it without stopping the run. Because QgsProcessingParameterDistance links to INPUT, the dialog shows the layer's units beside the value.

Let users run it in place

Feature-based algorithms can modify an editable layer directly, through the Edit Features In-Place mode of the Processing Toolbox. Opting in is one method, which should say yes only when the output would be compatible with the layer being edited.

    # add to NormaliseText
    def supportInPlaceEdit(self, layer):
        return super().supportInPlaceEdit(layer)

    # add to InteriorLabelPoints
    def supportInPlaceEdit(self, layer):
        return False

Breakdown: The base class implementation returns true when the layer is an editable vector layer and the algorithm's output geometry type matches the layer's — which is the case for the text normaliser, so deferring to it is correct. The label point algorithm turns polygons into points, which can never be written back into a polygon layer, so it opts out explicitly. In-place runs apply the changes to the layer's edit buffer, where they can be reviewed and undone before saving — the same buffer described in editing features with transactions. Opting out explicitly, rather than relying on the default, documents the decision for the next person who reads the class and protects against a future base-class change that relaxes the compatibility check.

New layer or the same layerLeft: a normal run reads the input and writes a new temporary or file output, leaving the source unchanged. Right: with Edit Features In-Place enabled in the Toolbox, the same processFeature output replaces features in the active editable layer's edit buffer, where the user can undo or save. The Toolbox only lists algorithms whose supportInPlaceEdit returns true for that layer.Same processFeature, two destinationsnormal runwrites a new output layersource unchangedevery feature-based algorithmin placewrites into the edit bufferundo or saveonly if supportInPlaceEdit is true

Dynamic parameters per feature

Some parameters make more sense as expressions evaluated per feature — a buffer distance from an attribute, a simplification tolerance that depends on area. Declaring a parameter as dynamic lets users switch it to a data-defined value in the dialog.

from qgis.core import QgsPropertyDefinition, QgsProcessingParameters

    # replacements for the methods of the same name in InteriorLabelPoints
    def initParameters(self, config=None):
        param = QgsProcessingParameterDistance(
            self.TOLERANCE, "Precision", defaultValue=1.0, parentParameterName="INPUT")
        param.setIsDynamic(True)
        param.setDynamicPropertyDefinition(QgsPropertyDefinition(
            self.TOLERANCE, "Precision", QgsPropertyDefinition.DoublePositive))
        param.setDynamicLayerParameterName("INPUT")
        self.addParameter(param)

    def prepareAlgorithm(self, parameters, context, feedback):
        self.tolerance = self.parameterAsDouble(parameters, self.TOLERANCE, context)
        self.dynamic = QgsProcessingParameters.isDynamic(parameters, self.TOLERANCE)
        self.tolerance_property = parameters[self.TOLERANCE] if self.dynamic else None
        return True

    def processFeature(self, feature, context, feedback):
        tolerance = self.tolerance
        if self.dynamic:
            expr_context = context.expressionContext()
            expr_context.setFeature(feature)
            tolerance, _ = self.tolerance_property.valueAsDouble(expr_context, self.tolerance)
        point, distance = feature.geometry().poleOfInaccessibility(tolerance)
        feature.setGeometry(point)
        feature.setAttributes(feature.attributes() + [distance])
        return [feature]

Breakdown: setIsDynamic adds the data-defined button next to the parameter in the dialog. When the user supplies an expression, the parameter value is a QgsProperty rather than a number, which isDynamic detects. Setting the feature on the context's expression context before evaluating makes field references in the expression resolve for the current feature. The static value remains the fallback when the expression returns null. Native algorithms such as buffer use exactly this pattern, which is why their distance can be driven by a field.

QGIS version compatibility

QgsProcessingFeatureBasedAlgorithm has existed since QGIS 3.0, and supportInPlaceEdit since 3.4. Dynamic parameters arrived in 3.4. QgsProcessing.TypeVectorPolygon becomes Qgis.ProcessingSourceType.VectorPolygon from 3.36, and QgsWkbTypes.Point becomes Qgis.WkbType.Point; the QGIS 4 series requires the scoped forms, as does QMetaType.Type for field types.

Troubleshooting

  • Features are silently missing from the output. They do not match the declared fields or geometry type.
  • The algorithm is not offered for in-place editing. The layer is not editable, or supportInPlaceEdit returns false.
  • Parameters are read as defaults. They were parsed in processFeature from stale values; parse in prepareAlgorithm.
  • Crash when running twice. createInstance returns self instead of a new instance.
  • Dynamic values ignored. The expression context was not given the current feature.

Conclusion

Use QgsProcessingFeatureBasedAlgorithm whenever an algorithm transforms features independently. Implement processFeature, parse parameters once in prepareAlgorithm, and describe any change to the output schema in outputFields and outputWkbType. Opt in to in-place editing only when output matches input, and make parameters dynamic where a per-feature expression is the natural way to set them.

Frequently Asked Questions

Can processFeature return several features? Yes — return a list with as many as needed, such as one per part when exploding multipart geometries.

Can I access other layers inside processFeature? Yes, but load them in prepareAlgorithm and keep only thread-safe objects, such as a spatial index built from a feature source.

How is this different from the @alg decorator? The decorator targets whole-dataset scripts; the feature-based class targets per-feature transforms with in-place support. See writing a Processing script with @alg.

Does it respect "selected features only"? Yes. The base class handles the input source, including selection and invalid geometry handling settings.