Write a Custom Feature Renderer in PyQGIS

A renderer decides which symbol each feature gets. QGIS ships renderers for one symbol, categories, graduated classes, rules, point clusters and more, and between them they handle almost everything — especially with expressions and data-defined overrides. Occasionally the choice depends on logic that does not fit an expression: a lookup against an external service, a stateful rule such as "highlight the five most recently edited features", or a symbology scheme shared by an organisation and maintained in code. A custom renderer in Python handles those cases while staying part of the normal rendering pipeline.

This recipe belongs to Extending QGIS with Custom Classes. It subclasses QgsFeatureRenderer, implements the methods the rendering engine calls, makes the renderer survive project save and load, registers it from a plugin, and explains when a built-in renderer is the better choice.

What the engine asks a rendererThe rendering engine calls startRender with the render context and fields, then usedAttributes to know which fields to fetch. For each feature it calls symbolForFeature, which returns the symbol to draw or None to skip the feature. symbols returns all symbols the renderer may use, for legends and preparation. stopRender ends the cycle. save and create serialise the renderer to and from XML in project and style files.The renderer's contract with the enginestartRender()context, fieldsusedAttributes()fields to fetchsymbolForFeature()per featureNone = skipsymbols()legend, preparestopRender()oncesave() / create(): XML in projects and QML

Prerequisites

Subclass QgsFeatureRenderer

The example renderer highlights features edited in the last N days: recent edits get a bold orange symbol, older ones a quiet grey, and features without an edit date are skipped. It is the kind of rule people often want for review maps.

from datetime import datetime, timedelta
from qgis.core import (QgsFeatureRenderer, QgsSymbol, QgsMarkerSymbol, QgsFillSymbol,
                       QgsLineSymbol, QgsWkbTypes)
from qgis.PyQt.QtGui import QColor

class RecentEditsRenderer(QgsFeatureRenderer):
    TYPE = "PyRecentEdits"

    def __init__(self, days=7, field="edited_on", recent=None, old=None):
        super().__init__(self.TYPE)
        self.days = days
        self.field = field
        self.recent = recent
        self.old = old
        self._cutoff = None
        self._idx = -1

    def _default_symbols(self, geometry_type):
        self.recent = self.recent or QgsSymbol.defaultSymbol(geometry_type)
        self.recent.setColor(QColor("#b45309"))
        self.old = self.old or QgsSymbol.defaultSymbol(geometry_type)
        self.old.setColor(QColor("#c9c2ac"))

    def startRender(self, context, fields):
        super().startRender(context, fields)
        self._cutoff = datetime.now() - timedelta(days=self.days)
        self._idx = fields.indexOf(self.field)
        self.recent.startRender(context, fields)
        self.old.startRender(context, fields)

    def stopRender(self, context):
        self.recent.stopRender(context)
        self.old.stopRender(context)
        super().stopRender(context)

    def usedAttributes(self, context):
        return {self.field}

    def symbolForFeature(self, feature, context):
        value = feature.attribute(self._idx) if self._idx >= 0 else None
        if value is None or (hasattr(value, "isNull") and value.isNull()):
            return None
        when = value.toPyDateTime() if hasattr(value, "toPyDateTime") else value
        return self.recent if when >= self._cutoff else self.old

    def symbols(self, context):
        return [self.recent, self.old]

    def clone(self):
        r = RecentEditsRenderer(self.days, self.field, self.recent.clone(), self.old.clone())
        self.copyRendererData(r)
        return r

Breakdown: The constructor passes the type name to the base class; it must match the registered name. startRender resolves the field index and cutoff once, and starts the symbols — a renderer is responsible for starting and stopping the symbols it hands out. usedAttributes tells the provider which fields to fetch, so the renderer works even when the layer's request is trimmed. Returning None from symbolForFeature skips the feature. copyRendererData in clone carries over properties common to all renderers, such as paint effects and ordering.

Make it survive saving

A renderer that cannot be written to XML disappears when the project is saved and reopened. save writes the renderer's state into a DOM element; a static create rebuilds it.

Saving and restoringOn save, the renderer writes a renderer-v2 element with its type, its own attributes such as days and field, and its symbols using QgsSymbolLayerUtils.saveSymbols. On load, the registry finds the type, calls the metadata's createRenderer with the element, and the static create method reads the attributes and loads the symbols back.XML out, XML insave()type, days, fieldsymbols elementrenderer-v2in .qgs / .qmlcreate()read attributesload symbols

from qgis.core import QgsSymbolLayerUtils, QgsReadWriteContext
from qgis.PyQt.QtXml import QDomDocument

    def save(self, doc, context):
        elem = doc.createElement("renderer-v2")
        elem.setAttribute("type", self.TYPE)
        elem.setAttribute("days", str(self.days))
        elem.setAttribute("field", self.field)
        symbols = QgsSymbolLayerUtils.saveSymbols({"recent": self.recent, "old": self.old},
                                                  "symbols", doc, context)
        elem.appendChild(symbols)
        self.saveRendererData(doc, elem, context)
        return elem

    @staticmethod
    def create(element, context):
        symbols = QgsSymbolLayerUtils.loadSymbols(element.firstChildElement("symbols"), context)
        r = RecentEditsRenderer(int(element.attribute("days", "7")),
                                element.attribute("field", "edited_on"),
                                symbols.get("recent"), symbols.get("old"))
        return r

Breakdown: These two methods belong inside the class above. The element name must be the one QGIS uses for renderers — renderer-v2 on all current releases — and the type attribute is how the registry finds your metadata on load. saveSymbols and loadSymbols write and read the symbols in QGIS's standard format, so they keep every symbol layer, colour and data-defined property. saveRendererData stores the common renderer properties that copyRendererData copies. Without this pair the renderer works in the session but the project reopens with a default single-symbol renderer.

Register the renderer type

As with symbol layers, a metadata object tells the renderer registry the type name, a display name and how to create instances from XML.

from qgis.core import QgsApplication, QgsRendererAbstractMetadata

class RecentEditsMetadata(QgsRendererAbstractMetadata):
    def __init__(self):
        super().__init__(RecentEditsRenderer.TYPE, "Recent edits (Python)")

    def createRenderer(self, element, context):
        return RecentEditsRenderer.create(element, context)

class RecentEditsPlugin:
    def __init__(self, iface):
        self.iface = iface
        self.meta = None

    def initGui(self):
        self.meta = RecentEditsMetadata()
        QgsApplication.rendererRegistry().addRenderer(self.meta)

    def unload(self):
        QgsApplication.rendererRegistry().removeRenderer(RecentEditsRenderer.TYPE)

Breakdown: After registration, the renderer type is known to QGIS and projects using it load correctly as long as the plugin is enabled before they open. Keeping the metadata on the plugin instance prevents garbage collection. Without a createRendererWidget implementation the renderer does not appear as a choice in the Layer Styling panel, but it can be applied from Python and survives saving — for many organisational renderers that is exactly right, since they are applied by a tool rather than chosen by hand.

Apply it to a layer

from qgis.core import QgsProject

edits = QgsProject.instance().mapLayersByName("inspections")[0]
renderer = RecentEditsRenderer(days=14, field="edited_on")
renderer._default_symbols(edits.geometryType())
edits.setRenderer(renderer)
edits.triggerRepaint()

Breakdown: Default symbols are created for the layer's geometry type, so the same renderer works on points, lines and polygons. Setting the renderer transfers ownership to the layer. From this point the layer behaves normally: it draws in the canvas, in layouts and in exports, and the legend shows the two symbols returned by symbols() — add legendSymbolItems to give them readable labels such as "edited in last 14 days".

Legend entries

The default legend shows unlabelled symbols. Overriding legendSymbolItems gives each symbol a label and lets users toggle them like categories.

Legend items from a rendererlegendSymbolItems returns a list of QgsLegendSymbolItem objects, each with a symbol, a label and a rule key. The Layers panel and layout legends display them. A stable rule key per item lets the legend remember which items are checked.Labelled legend entrieslegendSymbolItems()symbol · label · keyedited in last 14 daysolderstable keys let the legend remember check states

from qgis.core import QgsLegendSymbolItem

    def legendSymbolItems(self):
        return [QgsLegendSymbolItem(self.recent, f"edited in last {self.days} days", "recent"),
                QgsLegendSymbolItem(self.old, "older", "old")]

Breakdown: Each item carries a symbol, a label and a rule key; the key must be stable across sessions so the legend can remember which entries a user unchecked. To honour those check states while rendering, also implement legendSymbolItemsCheckable, legendSymbolItemChecked and checkLegendSymbolItem, and skip symbols whose item is unchecked in symbolForFeature.

Test the renderer headless

Renderers are easy to break in ways that only show on screen. A small test that renders the layer to an image with the custom renderer, and checks the symbol choice directly, catches regressions before users see them.

from qgis.core import (QgsMapSettings, QgsMapRendererSequentialJob, QgsRenderContext,
                       QgsFeature, QgsFields, QgsField)
from qgis.PyQt.QtCore import QSize, QVariant, QDateTime

def test_symbol_choice():
    fields = QgsFields()
    fields.append(QgsField("edited_on", QVariant.DateTime))
    r = RecentEditsRenderer(days=7)
    r._default_symbols(QgsWkbTypes.PointGeometry)
    ctx = QgsRenderContext()
    r.startRender(ctx, fields)
    f = QgsFeature(fields)
    f["edited_on"] = QDateTime.currentDateTime()
    assert r.symbolForFeature(f, ctx) is r.recent
    f["edited_on"] = QDateTime.currentDateTime().addDays(-30)
    assert r.symbolForFeature(f, ctx) is r.old
    r.stopRender(ctx)

settings = QgsMapSettings()
settings.setLayers([edits])
settings.setExtent(edits.extent())
settings.setOutputSize(QSize(600, 400))
job = QgsMapRendererSequentialJob(settings)
job.start(); job.waitForFinished()
job.renderedImage().save("/tmp/recent_edits_check.png")

Breakdown: The unit test calls startRender, symbolForFeature and stopRender directly with a synthetic feature, which pins down the renderer's logic without any canvas. The image render exercises the full pipeline — threads, symbol preparation, legend-independent drawing — and a saved PNG can be compared against a reference image in CI, as described in unit testing a QGIS plugin with pytest. Run both after every change to the renderer.

When not to write a renderer

Most "custom logic" fits a rule-based renderer with expressions — including the example here, which could be two rules on "edited_on" >= now() - '14 days'. Rule-based renderers need no plugin, are portable to any QGIS, and are configured in the GUI. Choose a Python renderer when the logic genuinely needs Python: external lookups, state across features, or a scheme your organisation wants maintained as code and versioned in a plugin. Rule-based renderers are the first thing to try.

QGIS version compatibility

Subclassing QgsFeatureRenderer and registering with QgsRendererAbstractMetadata work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. The rendering engine may call symbolForFeature from worker threads, so the method must be thread-safe: read only state prepared in startRender. On QGIS 4, geometry type enums are Qgis.GeometryType.

Troubleshooting

  • The layer draws nothing. symbolForFeature returns None for every feature — check the field index and NULL handling.
  • Symbols draw incorrectly or crash. They were not started in startRender.
  • The project reopens with a default renderer. save and create are missing, or the plugin loads after the project.
  • Attributes are always NULL. The field is not in usedAttributes, so the provider did not fetch it.

Conclusion

Subclass QgsFeatureRenderer with a unique type, prepare state and start symbols in startRender, declare fields in usedAttributes, choose symbols in a thread-safe symbolForFeature, serialise with save and create, register metadata from a plugin, label the legend, and prefer rule-based renderers whenever an expression can express the logic.

Frequently Asked Questions

Can a custom renderer draw things that are not symbols? It can override renderFeature for full control, but drawing through symbols keeps legends, exports and effects working.

Does the renderer work in print layouts? Yes; layouts use the same rendering engine.

Can I wrap an existing renderer and modify its output? Yes — hold an embedded renderer and delegate to it, adjusting symbols as needed. QGIS's own inverted polygon and point displacement renderers work this way.

Will QGIS Server use it? Only if the plugin is installed and loaded as a server plugin.