Style Processing Algorithm Outputs in PyQGIS
A custom Processing algorithm that produces a risk classification, a heat surface or a network of flow lines is only half done if the result lands on the map in a random single colour. Users then have to restyle it by hand every time, and they will each do it differently. Processing lets an algorithm style its own outputs: once the algorithm has finished and its layers have been loaded into the project, a post-processor runs on the main thread and can apply a renderer, a saved QML style, a layer name, or anything else the result needs.
This recipe belongs to Processing Provider Plugins and builds on writing a custom Processing algorithm. It styles outputs with a layer post-processor, applies QML files shipped with the plugin, builds renderers in code, names output layers, and makes sure styling never interferes with headless runs.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- A Processing algorithm in a plugin provider, as in registering a Processing provider.
Attach a layer post-processor
A post-processor is a small class with a postProcessLayer method. The algorithm creates one per output and registers it with the output's layer details in the context.
from qgis.core import (QgsProcessingAlgorithm, QgsProcessingLayerPostProcessorInterface,
QgsProcessingParameterFeatureSink, QgsProcessingParameterFeatureSource,
QgsProcessing, QgsFeatureSink)
class RiskStyler(QgsProcessingLayerPostProcessorInterface):
instance = None
def postProcessLayer(self, layer, context, feedback):
if not layer.isValid():
return
layer.loadNamedStyle(STYLE_PATH) # see below
layer.triggerRepaint()
@staticmethod
def create():
RiskStyler.instance = RiskStyler() # keep a reference alive
return RiskStyler.instance
class ClassifyRisk(QgsProcessingAlgorithm):
INPUT, OUTPUT = "INPUT", "OUTPUT"
def initAlgorithm(self, config=None):
self.addParameter(QgsProcessingParameterFeatureSource(self.INPUT, "Assets",
[QgsProcessing.TypeVectorPoint]))
self.addParameter(QgsProcessingParameterFeatureSink(self.OUTPUT, "Risk classes"))
def processAlgorithm(self, parameters, context, feedback):
source = self.parameterAsSource(parameters, self.INPUT, context)
sink, dest_id = self.parameterAsSink(parameters, self.OUTPUT, context,
source.fields(), source.wkbType(), source.sourceCrs())
for f in source.getFeatures():
sink.addFeature(f, QgsFeatureSink.FastInsert)
if context.willLoadLayerOnCompletion(dest_id):
details = context.layerToLoadOnCompletionDetails(dest_id)
details.setPostProcessor(RiskStyler.create())
return {self.OUTPUT: dest_id}
def name(self): return "classify_risk"
def displayName(self): return "Classify risk"
def createInstance(self): return ClassifyRisk()
Breakdown: willLoadLayerOnCompletion is true only when Processing is going to add this output to the project — when run from the toolbox with "Open output file" checked, or from processing.runAndLoadResults. In that case layerToLoadOnCompletionDetails returns the details for the output, and setPostProcessor attaches the styler. The post-processor object must stay alive until it runs; a class-level reference handles that, since Python would otherwise garbage collect it while the algorithm returns. postProcessLayer receives the loaded layer on the main thread, where applying styles is safe.
Ship QML styles with the plugin
Styles designed interactively in QGIS and saved as QML files are the easiest to maintain. Put them in the plugin folder and load them by path.
import os
PLUGIN_DIR = os.path.dirname(__file__)
STYLE_PATH = os.path.join(PLUGIN_DIR, "styles", "risk_classes.qml")
class QmlStyler(QgsProcessingLayerPostProcessorInterface):
_alive = []
def __init__(self, qml):
super().__init__()
self.qml = qml
def postProcessLayer(self, layer, context, feedback):
message, ok = layer.loadNamedStyle(self.qml)
if not ok:
feedback.reportError(f"Style not applied: {message}")
layer.triggerRepaint()
@classmethod
def create(cls, qml):
obj = cls(qml)
cls._alive.append(obj)
return obj
Breakdown: Paths resolved from __file__ work wherever the plugin is installed. loadNamedStyle returns a message and a success flag; reporting failures through feedback puts them in the algorithm's log, where users will see them. A generic QmlStyler that takes the QML path serves every output of every algorithm in the provider. The QML must match the output's field names — a categorised renderer keyed on risk_class needs that field to exist. Styles can also be saved with layer styling code if you prefer them in Python.
Build the renderer in code
When the style depends on the data — class breaks computed from the result, or categories that are only known after running — build the renderer in the post-processor.
from qgis.core import (QgsGraduatedSymbolRenderer, QgsClassificationQuantile,
QgsStyle, QgsSymbol)
class GraduatedStyler(QgsProcessingLayerPostProcessorInterface):
_alive = []
def __init__(self, field, classes=5, ramp="Reds"):
super().__init__()
self.field, self.classes, self.ramp = field, classes, ramp
def postProcessLayer(self, layer, context, feedback):
renderer = QgsGraduatedSymbolRenderer(self.field)
renderer.setClassificationMethod(QgsClassificationQuantile())
renderer.setSourceSymbol(QgsSymbol.defaultSymbol(layer.geometryType()))
renderer.setSourceColorRamp(QgsStyle.defaultStyle().colorRamp(self.ramp))
renderer.updateClasses(layer, self.classes)
layer.setRenderer(renderer)
layer.triggerRepaint()
@classmethod
def create(cls, *args, **kwargs):
obj = cls(*args, **kwargs)
cls._alive.append(obj)
return obj
Breakdown: The graduated renderer classifies the chosen field into quantiles computed from the actual output, using a colour ramp from the default style library. Because the class breaks come from the result, each run is styled to its own data range. The same approach suits categorised renderers whose categories are the unique values found, as in graduated and categorised renderers.
Name output layers
By default outputs load with the parameter's description as the layer name. The layer details let the algorithm give a more useful name.
def processAlgorithm(self, parameters, context, feedback):
source = self.parameterAsSource(parameters, self.INPUT, context)
# ... create sink and write features as before ...
if context.willLoadLayerOnCompletion(dest_id):
details = context.layerToLoadOnCompletionDetails(dest_id)
details.name = f"Risk – {source.sourceName()}"
details.setPostProcessor(QmlStyler.create(STYLE_PATH))
return {self.OUTPUT: dest_id}
Breakdown: Setting details.name changes the name the layer receives when it is added. Including the input's name and key parameters makes several runs distinguishable in the Layers panel. Some users enable the Processing option to name outputs after their file names; an explicit name from the algorithm is clearer for most results.
Keep styling out of headless runs
Styling only makes sense when a layer is being added to a project. In scripts, models and headless runs, outputs are just files.
import processing
result = processing.run("assets:classify_risk",
{"INPUT": "/data/assets.gpkg", "OUTPUT": "/data/risk.gpkg"})
print(result["OUTPUT"]) # a file path; no post-processor ran
processing.runAndLoadResults("assets:classify_risk",
{"INPUT": "/data/assets.gpkg", "OUTPUT": "TEMPORARY_OUTPUT"})
Breakdown: processing.run does not load outputs, so willLoadLayerOnCompletion is false and no styling code runs — exactly right for batch jobs and servers. runAndLoadResults behaves like the toolbox and triggers the post-processor. Guarding with willLoadLayerOnCompletion is what keeps one algorithm correct in both situations. If headless users also want styles, write a sidecar .qml next to file outputs, which QGIS picks up automatically when the file is opened later.
Place outputs in a group
Algorithms that produce several layers — a result, a summary table and a flagged-errors layer, say — clutter the Layers panel when each lands at the top. A post-processor can move its layer into a named group after loading.
from qgis.core import QgsProject
class GroupPlacer(QgsProcessingLayerPostProcessorInterface):
_alive = []
def __init__(self, group_name, qml=None):
super().__init__()
self.group_name, self.qml = group_name, qml
def postProcessLayer(self, layer, context, feedback):
if self.qml:
layer.loadNamedStyle(self.qml)
root = QgsProject.instance().layerTreeRoot()
group = root.findGroup(self.group_name) or root.insertGroup(0, self.group_name)
node = root.findLayer(layer.id())
if node is not None:
group.insertChildNode(0, node.clone())
node.parent().removeChildNode(node)
@classmethod
def create(cls, *args):
obj = cls(*args)
cls._alive.append(obj)
return obj
Breakdown: The group is created at the top of the tree on first use and reused afterwards, so repeated runs collect their results together. Moving a layer tree node is done by inserting a clone in the new parent and removing the original, the standard pattern for working with the layer tree. Combining grouping with styling in one post-processor keeps each output's presentation in one place.
Test the styling
Post-processors are ordinary Python classes, so they can be tested without running the algorithm. Create a memory layer with the fields the output will have, call postProcessLayer directly, and assert on the renderer type, the number of classes, or the layer name. A test like this catches the most common regression — a field renamed in the algorithm but not in the QML — long before a user sees an unstyled result. Running the whole algorithm with runAndLoadResults in an integration test, as described in testing Processing algorithms with pytest, then confirms that the post-processor is actually attached.
QGIS version compatibility
QgsProcessingLayerPostProcessorInterface, layerToLoadOnCompletionDetails, setPostProcessor and details.name are available on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. Enum names may appear in scoped form in QGIS 4; the renderer and style APIs used here are unchanged.
Troubleshooting
- The style is never applied. The post-processor was garbage collected; keep a reference to it.
- QGIS crashes when styling. Styling was done in
processAlgorithm; move it into the post-processor. - A categorised style shows everything as "other". The QML expects field names or values that the output does not have.
- Styles apply in the toolbox but not in a model. Intermediate model outputs are not loaded; only final outputs receive post-processors.
Conclusion
Attach a layer post-processor to each output that will be loaded, apply QML styles shipped with the plugin or build renderers from the result's data, name outputs meaningfully, keep references to post-processors alive, and guard everything with willLoadLayerOnCompletion so headless runs stay untouched.
Frequently Asked Questions
Can one post-processor serve several outputs? Yes; attach a separate instance, or the same class with different parameters, to each.
Can I style raster outputs?
Yes; the post-processor receives raster layers too, and loadNamedStyle or a raster renderer works the same way.
Can I add the output to a specific group? Yes; in the post-processor, move the layer's tree node into a group of your choice.
Does this work for scripts using the @alg decorator? Yes; the decorator produces a normal algorithm with access to the same context.