Write a Custom Symbol Layer in PyQGIS

QGIS's symbol layers — simple markers, SVG markers, font markers, geometry generators — cover an enormous range of cartography. Occasionally a map needs a mark they cannot draw: a wind barb computed from two attributes, a domain-specific glyph such as a geological dip symbol, a tiny sparkline at each station. A custom symbol layer written in Python plugs into the same system as the built-in ones: it appears in the symbol selector, stacks with other layers, follows data-defined size and rotation, and is drawn by the normal renderer.

This recipe belongs to Extending QGIS with Custom Classes. It subclasses QgsMarkerSymbolLayer, draws with QPainter in the right units, stores properties so styles survive saving, registers the class from a plugin, and covers performance and the limits of Python symbol layers.

Where a custom symbol layer fitsA symbol is a stack of symbol layers. Built-in layers and a custom Python marker layer sit side by side in the stack. The symbol layer registry knows each type by name through a metadata object, which creates instances from saved properties. At render time the renderer asks the symbol to draw each feature, and each layer's renderPoint receives the point in painter coordinates and a render context with the painter and unit conversions.One more layer in the symbol stackQgsMarkerSymbolsimple markeryour Python layerSVG markersymbol layer registrymetadata: name →createSymbolLayer()renderPoint()QPainter + contextmap canvas,layouts, exportsstyles save via properties()

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series.
  • A plugin to host the code. Symbol layer types must be registered every time QGIS starts, which a plugin's initGui (or a startup script) does; see creating a plugin with Plugin Builder.
  • Basic QPainter knowledge: pens, brushes, drawLine, drawEllipse, save and restore.

Subclass QgsMarkerSymbolLayer

A marker symbol layer needs a type name, a way to describe its properties, a clone method, and renderPoint. The example draws a simple wind barb: a staff pointing into the wind with one feather per 10 knots.

import math
from qgis.core import (QgsMarkerSymbolLayer, QgsSymbolLayerUtils, QgsUnitTypes,
                       QgsRenderContext)
from qgis.PyQt.QtCore import QPointF
from qgis.PyQt.QtGui import QColor, QPen

class WindBarbSymbolLayer(QgsMarkerSymbolLayer):
    TYPE = "PyWindBarb"

    def __init__(self, color=QColor("#17211d"), size=8.0, speed_field="speed_kn"):
        super().__init__()
        self.setColor(color)
        self.setSize(size)                        # staff length, millimetres
        self.speed_field = speed_field
        self._pen = None

    def layerType(self):
        return self.TYPE

    def properties(self):
        return {"color": QgsSymbolLayerUtils.encodeColor(self.color()),
                "size": str(self.size()),
                "speed_field": self.speed_field}

    @staticmethod
    def create(props):
        return WindBarbSymbolLayer(
            QgsSymbolLayerUtils.decodeColor(props.get("color", "#17211d")),
            float(props.get("size", 8.0)),
            props.get("speed_field", "speed_kn"))

    def clone(self):
        c = WindBarbSymbolLayer(self.color(), self.size(), self.speed_field)
        self.copyDataDefinedProperties(c)
        self.copyPaintEffect(c)
        return c

Breakdown: layerType() returns the name the registry uses to find the class, so it must be unique — a prefix such as Py avoids clashes with built-in types. properties() returns everything needed to recreate the layer as strings; QGIS writes this dictionary into .qgs and .qml files, and create() reads it back. QgsSymbolLayerUtils.encodeColor and decodeColor handle colours the same way built-in layers do. clone() must copy data-defined properties and paint effects explicitly, or a duplicated symbol silently loses them.

Draw in renderPoint

renderPoint receives the feature's position already converted to painter coordinates, plus a symbol render context giving access to the painter, the feature and unit conversions. Expensive setup belongs in startRender, which runs once per render rather than once per feature.

The render cyclestartRender runs once before drawing a layer and prepares pens and field indexes. renderPoint runs for every feature with the point in painter pixels and the feature available from the context. stopRender runs once afterwards to release resources. Sizes are converted from millimetres to pixels with the render context so output is correct on screen and in high resolution exports.Prepare once, draw many, clean up oncestartRender()pens, field indexonce per renderrenderPoint()per featuremm → pixelsstopRender()releaseoncekeep renderPoint free of allocations and lookups

    def startRender(self, context):
        self._pen = QPen(self.color())
        self._pen.setCapStyle(1)                 # Qt.FlatCap
        rc = context.renderContext()
        self._pen.setWidthF(rc.convertToPainterUnits(0.3, QgsUnitTypes.RenderMillimeters))
        self._staff = rc.convertToPainterUnits(self.size(), QgsUnitTypes.RenderMillimeters)

    def stopRender(self, context):
        self._pen = None

    def renderPoint(self, point, context):
        feature = context.feature()
        if feature is None:
            return
        speed = feature[self.speed_field] or 0
        direction = feature["dir_deg"] or 0
        painter = context.renderContext().painter()
        painter.save()
        painter.setPen(self._pen)
        painter.translate(point)
        painter.rotate(direction)                # 0 = wind from north
        painter.drawLine(QPointF(0, 0), QPointF(0, -self._staff))
        feathers = int(round(speed / 10))
        step = self._staff / 6
        for i in range(min(feathers, 5)):
            y = -self._staff + i * step
            painter.drawLine(QPointF(0, y), QPointF(self._staff * 0.35, y - step * 0.8))
        painter.restore()

Breakdown: Converting millimetres to painter units through the render context is essential: on screen a millimetre is a few pixels, in a 300 dpi export it is about twelve, and hard-coded pixel sizes produce symbols that shrink in print. context.feature() gives the feature being drawn, so attributes can drive the drawing. painter.save() and restore() around the translation and rotation keep the painter clean for the next layer. In startRender, also resolve field indexes once if you read attributes by index; looking names up in every renderPoint call costs time on large layers.

Register the type from a plugin

The registry needs a metadata object that names the type and knows how to create instances from properties. Register it when the plugin loads and remove it when it unloads.

from qgis.core import QgsApplication, QgsSymbolLayerAbstractMetadata, Qgis

class WindBarbMetadata(QgsSymbolLayerAbstractMetadata):
    def __init__(self):
        super().__init__(WindBarbSymbolLayer.TYPE, "Wind barb (Python)",
                         Qgis.SymbolType.Marker)

    def createSymbolLayer(self, props):
        return WindBarbSymbolLayer.create(props)

class WindBarbPlugin:
    def __init__(self, iface):
        self.iface = iface
        self.metadata = None

    def initGui(self):
        self.metadata = WindBarbMetadata()
        QgsApplication.symbolLayerRegistry().addSymbolLayerType(self.metadata)

    def unload(self):
        registry = QgsApplication.symbolLayerRegistry()
        if hasattr(registry, "removeSymbolLayerType"):
            registry.removeSymbolLayerType(self.metadata)

Breakdown: After registration, "Wind barb (Python)" appears in the symbol layer type list of the symbol selector, and projects that use it load correctly — provided the plugin is enabled before the project opens. Keeping a reference to the metadata object on the plugin instance prevents Python from garbage-collecting it while QGIS still uses it. removeSymbolLayerType is only available on recent releases, hence the hasattr guard; where it is missing there is no clean removal, which is one reason symbol layer plugins usually ask for a restart after an update. On QGIS 3.x before 3.30, the symbol type enum is QgsSymbol.Marker.

Use it from Python and in styles

Once registered, the layer is used like any built-in one: added to symbols, saved in QML, set as part of a renderer.

from qgis.core import QgsProject, QgsMarkerSymbol, QgsSingleSymbolRenderer

stations = QgsProject.instance().mapLayersByName("weather_stations")[0]
symbol = QgsMarkerSymbol()
symbol.changeSymbolLayer(0, WindBarbSymbolLayer(size=9))
stations.setRenderer(QgsSingleSymbolRenderer(symbol))
stations.triggerRepaint()

stations.saveNamedStyle("/data/styles/wind_barbs.qml")

Breakdown: Replacing the default simple marker with the custom layer gives a symbol made of the barb alone; appendSymbolLayer would draw it on top of a dot instead. The saved QML contains the layer type and its properties; any QGIS with the plugin enabled can load it, while QGIS without the plugin reports an unknown symbol layer type and substitutes a default marker. Built-in data-defined properties such as size and angle work automatically if your drawing code reads self.size() and lets the framework apply rotation — the example rotates itself from an attribute, which is simpler to follow but bypasses the standard angle property.

Add a settings widget

Without a widget, users can add the layer but not change its properties in the symbol selector. A small QgsSymbolLayerWidget subclass returned from the metadata's createSymbolLayerWidget fills that gap.

Metadata, layer and widgetThe metadata object creates both the symbol layer from properties and, optionally, a settings widget for the symbol selector. The widget receives the layer through setSymbolLayer, edits it, and emits changed so the preview and map update. Without a widget the layer works but shows no settings.Optional, but users will expect itmetadatacreateSymbolLayercreateSymbolLayerWidgetsymbol layerdrawn on the mapsettings widgetedits the layerchanged signalpreview refresh

from qgis.gui import QgsSymbolLayerWidget
from qgis.PyQt.QtWidgets import QDoubleSpinBox, QLineEdit, QFormLayout

class WindBarbWidget(QgsSymbolLayerWidget):
    def __init__(self, vector_layer=None):
        super().__init__(None, vector_layer)
        self.layer = None
        self.size = QDoubleSpinBox(); self.size.setRange(1, 50); self.size.setSuffix(" mm")
        self.field = QLineEdit()
        form = QFormLayout(self); form.addRow("Staff length", self.size); form.addRow("Speed field", self.field)
        self.size.valueChanged.connect(self._update)
        self.field.editingFinished.connect(self._update)

    def setSymbolLayer(self, layer):
        self.layer = layer
        self.size.setValue(layer.size()); self.field.setText(layer.speed_field)

    def symbolLayer(self):
        return self.layer

    def _update(self):
        self.layer.setSize(self.size.value()); self.layer.speed_field = self.field.text()
        self.changed.emit()

# in WindBarbMetadata:
#     def createSymbolLayerWidget(self, vector_layer):
#         return WindBarbWidget(vector_layer)

Breakdown: The widget holds a reference to the layer being edited, updates it directly, and emits changed so the symbol selector redraws its preview. A QgsFieldExpressionWidget in place of the line edit would let users pick the speed field from the layer — the QGIS custom widgets recipe shows it.

Performance and limits

Python symbol layers run Python code for every feature in every render, including every pan and zoom. A few thousand features render comfortably; hundreds of thousands will feel slow. Keep renderPoint minimal — no allocations, no attribute lookups by name, no geometry operations — and precompute in startRender. Rendering happens in worker threads, so the layer must not touch the GUI or shared mutable state. For many needs, a geometry generator or data-defined built-in layer is fast enough and requires no plugin; reach for a custom class when the drawing logic genuinely cannot be expressed that way.

QGIS version compatibility

QgsMarkerSymbolLayer subclassing and QgsSymbolLayerAbstractMetadata work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. Qgis.SymbolType.Marker exists from 3.20; earlier code uses QgsSymbol.Marker. Removing a registered type needs a recent release, as noted above. On QGIS 4, QgsUnitTypes.RenderMillimeters is Qgis.RenderUnit.Millimeters, and QPen.setCapStyle takes Qt.PenCapStyle.FlatCap.

Troubleshooting

  • The type does not appear in the selector. Registration ran after the selector was opened, or the metadata was garbage-collected; keep a reference.
  • Symbols are tiny in exports. Sizes were in pixels; convert from millimetres through the render context.
  • Projects show a default marker instead. The plugin was not enabled before the project loaded.
  • QGIS crashes while panning. The layer touches GUI objects or shared state from the render thread.

Conclusion

Subclass QgsMarkerSymbolLayer with a unique type name, serialise everything in properties() and restore it in create(), copy data-defined properties in clone(), prepare in startRender and draw in renderPoint with unit conversion, register through metadata from a plugin, add a settings widget, and keep the per-feature code lean.

Frequently Asked Questions

Can I write line or fill symbol layers too? Yes: subclass QgsLineSymbolLayer (renderPolyline) or QgsFillSymbolLayer (renderPolygon) in the same way.

Will the symbol work in QGIS Server? Only if the plugin is installed and loaded on the server, which server plugins support.

Does SLD export include my layer? No. Custom layers have no SLD representation unless you implement toSld.

Can I use SVG files instead? Often yes — an SVG marker with data-defined parameters may avoid custom code entirely; see using SVG marker symbols.