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.
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.
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.
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
supportInPlaceEditreturns false. - Parameters are read as defaults. They were parsed in
processFeaturefrom stale values; parse inprepareAlgorithm. - Crash when running twice.
createInstancereturnsselfinstead 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.