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.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- A plugin to register the renderer type each time QGIS starts.
- Familiarity with symbols from setting vector layer symbol colours.
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.
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.
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.
symbolForFeaturereturnsNonefor 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.
saveandcreateare 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.