Stack Symbol Layers in PyQGIS
Almost every symbol in a good QGIS map is more than one thing drawn on top of another. A road is a dark wide line with a bright narrow line over it. A boundary is a soft band with a hard edge on its inner side. A building has a fill, an outline, and a slightly offset copy underneath it doing the work of a shadow. In PyQGIS all three are the same operation: append symbol layers to a symbol in the order they should be painted.
This recipe belongs to Symbol Layers & Advanced Symbology in PyQGIS. It covers building a stack from scratch, inserting into an existing one, cloning a stack across the classes of a categorized renderer, and the two ordering mistakes that account for most "it looks nothing like the GUI" reports.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer; the API used here is stable back to 3.16.
- A loaded vector layer. The examples use a line layer named
roadsand a polygon layer namedzoning. - A single-symbol renderer to start from. If the layer is already categorized, read graduated and categorized renderers first — the stack is built the same way, but you assign it per category.
Build a stack from nothing
The most predictable route is to construct an empty symbol and append every layer yourself, so nothing is inherited from a default you did not choose.
from qgis.core import QgsProject, QgsLineSymbol, QgsSimpleLineSymbolLayer
from qgis.PyQt.QtGui import QColor
from qgis.PyQt.QtCore import Qt
layer = QgsProject.instance().mapLayersByName("roads")[0]
symbol = QgsLineSymbol()
symbol.deleteSymbolLayer(0)
for colour, width in (("#3f3f46", 1.8), ("#fbbf24", 1.1)):
line = QgsSimpleLineSymbolLayer(QColor(colour))
line.setWidth(width)
line.setPenCapStyle(Qt.RoundCap)
line.setPenJoinStyle(Qt.RoundJoin)
symbol.appendSymbolLayer(line)
layer.renderer().setSymbol(symbol)
layer.triggerRepaint()
Breakdown: QgsLineSymbol() arrives with one default symbol layer already in it, and deleteSymbolLayer(0) is what stops that default blue line showing through at every join. The loop appends in painting order — dark first, bright second — because appendSymbolLayer() adds to the end of the list and the end of the list is drawn last. Round caps and joins are set on both layers so the casing wraps the fill at corners; with the default flat caps the bright line pokes out past the dark one at every bend.
setWidth() is in millimetres. If the road should keep its real ground width as the map zooms, switch the unit instead of guessing a number:
from qgis.core import QgsUnitTypes
casing = symbol.symbolLayer(0)
casing.setOutputUnit(QgsUnitTypes.RenderMetersInMapUnits)
casing.setWidth(9.0) # nine metres of carriageway
Breakdown: In map-unit mode the width is a distance on the ground, so the line shrinks as you zoom out and eventually disappears — correct for a cadastral plan, wrong for a national overview where a road must stay visible at any scale. Mixing units within one stack is legal and occasionally right: a ground-width casing under a hairline millimetre centre line keeps the road findable when zoomed out.
Insert into a stack you did not build
When the layer already has a symbol you like and you only want to add to it, do not rebuild — insert.
from qgis.core import QgsSimpleLineSymbolLayer
from qgis.PyQt.QtGui import QColor
from qgis.PyQt.QtCore import Qt
symbol = layer.renderer().symbol()
centre = QgsSimpleLineSymbolLayer(QColor("#fffdf7"))
centre.setWidth(0.3)
centre.setPenStyle(Qt.DashLine)
symbol.appendSymbolLayer(centre) # on top of everything
symbol.insertSymbolLayer(0, shadow) # underneath everything
layer.triggerRepaint()
Breakdown: appendSymbolLayer() and insertSymbolLayer(index, layer) are the only two ways in; there is no setSymbolLayer that takes an arbitrary position. Inserting at 0 is how you slide something under an existing design without touching it, which is the whole trick behind adding a drop shadow to a symbol somebody else authored. Note that the symbol returned by renderer().symbol() is the live object, so these calls take effect immediately and there is nothing to assign back.
Removing works the same way, and the index shifts as you go — deleting layers in ascending order will delete the wrong ones after the first:
for index in range(symbol.symbolLayerCount() - 1, -1, -1):
if symbol.symbolLayer(index).layerType() == "SimpleLine":
symbol.deleteSymbolLayer(index)
Breakdown: Iterating backwards is the standard defence against index shifting. layerType() returns the registry name of the class, so this is also how you find "the SVG marker somewhere in this stack" without knowing where the previous author put it.
A hatched fill under a crisp outline
Polygons show off the stack better than lines, because the fill and the border genuinely want different treatments.
from qgis.core import (
QgsFillSymbol, QgsLinePatternFillSymbolLayer,
QgsSimpleFillSymbolLayer, QgsSimpleLineSymbolLayer, QgsLineSymbol,
)
from qgis.PyQt.QtGui import QColor
from qgis.PyQt.QtCore import Qt
hatch = QgsLinePatternFillSymbolLayer()
hatch.setLineAngle(45)
hatch.setDistance(2.2)
hatch_line = QgsSimpleLineSymbolLayer(QColor("#b45309"))
hatch_line.setWidth(0.3)
hatch.setSubSymbol(QgsLineSymbol([hatch_line]))
outline = QgsSimpleFillSymbolLayer(QColor("#000000"))
outline.setBrushStyle(Qt.NoBrush)
outline.setStrokeColor(QColor("#7c2d12"))
outline.setStrokeWidth(0.6)
symbol = QgsFillSymbol()
symbol.deleteSymbolLayer(0)
symbol.appendSymbolLayer(hatch)
symbol.appendSymbolLayer(outline)
zoning = QgsProject.instance().mapLayersByName("zoning")[0]
zoning.renderer().setSymbol(symbol)
zoning.triggerRepaint()
Breakdown: The hatch's own line is set through setSubSymbol(), which takes a whole QgsLineSymbol rather than a symbol layer — the same nesting rule that applies to marker lines and point-pattern fills. Qt.NoBrush on the outline layer is what makes it an outline rather than a second fill; without it you have painted an opaque black polygon over your hatch. Putting the outline last is not cosmetic: a hatch drawn over the border leaves ragged ends at the polygon edge, and the crisp line on top hides them.
setDistance() is the spacing between hatch lines in the layer's output unit. Anything under about 1.5 mm produces a solid-looking wash at print resolution and an enormous number of primitives at draw time, so it is the first thing to raise when a hatched layer makes the canvas crawl.
Reuse a stack across renderer classes
Building an elaborate stack once and applying it to every class of a categorized renderer is the normal case, and it has one hazard.
for category in renderer.categories():
clone = template_symbol.clone()
clone.symbolLayer(1).setColor(category_colour[category.value()])
renderer.updateCategorySymbol(renderer.categories().index(category), clone)
Breakdown: clone() is mandatory. Symbols are reference types, so assigning the same object to several categories makes them share every subsequent edit — change one colour and all of them change, which is exactly the bug that looks like "the renderer is ignoring my colours". Cloning also copies the whole stack including data-defined overrides, so a template built once carries its expressions everywhere.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.16 LTR | 3.7 | appendSymbolLayer / insertSymbolLayer / deleteSymbolLayer all present and unchanged. |
| 3.22 LTR | 3.9 | QgsUnitTypes.RenderMetersInMapUnits available for ground-unit widths. |
| 3.28 LTR | 3.9 | Symbol-layer property enums still flat (QgsSymbolLayer.PropertyStrokeWidth). |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.36+ | 3.12 | Property enums gain the scoped QgsSymbolLayer.Property.StrokeWidth form; flat names remain as aliases. |
Troubleshooting
- A stray blue line appears under everything. The symbol's default layer 0 was never deleted. Call
deleteSymbolLayer(0)immediately after constructing the symbol. - The bright line escapes the casing at corners. Caps and joins default to flat and mitre. Set
Qt.RoundCapandQt.RoundJoinon both layers. - The canvas does not change.
triggerRepaint()was not called, or the renderer is categorized andrenderer().symbol()raised on the way past — checktype(layer.renderer()).__name__. - Changing one category's colour changes them all. Symbols were assigned by reference. Use
symbol.clone()per category. - The legend still shows the old symbol. Repainting the canvas does not refresh the layer tree. Call
iface.layerTreeView().refreshLayerSymbology(layer.id()). - The hatch overruns the polygon edge. The outline layer is below the pattern fill. Move it to the top of the stack.
- The layer became slow after adding a pattern fill. The hatch spacing is too fine. Raise
setDistance()or switch to a plain fill at small scales.
Conclusion
A symbol is a list, and the list is painted from index 0 upward. Construct the symbol, delete the default layer, append in painting order, and repaint. Use insertSymbolLayer(0, …) to slide something beneath a design you want to keep, iterate backwards when deleting, and clone before assigning a stack to more than one renderer class.
Frequently Asked Questions
How do I copy a stack I built in the GUI into a script?
Style the layer in the interface, then in the Python console run [sl.properties() for sl in layer.renderer().symbol().symbolLayers()]. Each dictionary can be passed straight to QgsSymbolLayerRegistry.instance().createSymbolLayer(type_name, props), or more simply to the matching createSimple() helper.
Can symbol layers in one stack use different units?
Yes — each symbol layer carries its own outputUnit(). Mixing a map-unit casing with a millimetre centre line is a deliberate and useful combination, because the casing tracks the real road width while the centre line stays visible at every scale.
Is there a limit on stack depth? No hard limit, but rendering cost is roughly the number of layers multiplied by the number of features. Three or four layers is normal, above six is usually a sign that a geometry generator or a second map layer would express the idea more cheaply.
How do I turn one layer of the stack off without deleting it?
Call symbol_layer.setEnabled(False). The layer stays in the list with its settings intact, which is far friendlier than deleting and rebuilding when you are experimenting.
Does saving the project preserve the stack? Yes. The full symbol definition is written into the project file, and saving a QML style exports the same definition as a portable sidecar you can apply to another layer.