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.
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
QPainterknowledge: pens, brushes,drawLine,drawEllipse,saveandrestore.
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.
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.
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.