Symbol Layers & Advanced Symbology in PyQGIS

A renderer decides which symbol each feature gets. A symbol decides how that feature is drawn — and in QGIS a symbol is never a single instruction. It is a stack of symbol layers, drawn bottom to top, each with its own colour, width, offset and its own set of properties that can be driven by an expression. Everything that looks sophisticated in QGIS cartography — a road casing, a dashed border inside a solid one, a hatched fill under a crisp outline, a marker that grows with a value — is that stack being used properly.

This guide sits inside PyQGIS Cartography & Data Visualization and picks up where programmatic layer styling stops. Styling gets you a symbol with a colour; symbology gets you a symbol that carries information. If you are choosing symbols per feature class rather than composing one, graduated and categorized renderers is the page you want first, then come back here to make each of those classes look like something.

Layer, renderer, symbol, symbol layerA vector layer owns one renderer. The renderer owns one or more symbols, chosen per feature. Each symbol owns an ordered list of symbol layers. The symbol layers are painted in index order, so index zero is painted first and appears underneath everything above it.One layer, one renderer, many symbols, each a stackQgsVectorLayerthe data + its stylerendererpicks a symbol per featureQgsSymbola stack, not a coloursymbol layersordered listsymbol.symbolLayers() — painted in index orderindex 0 — wide dark line (the casing), painted firstindex 1 — narrow bright line, painted on topreverse the order and the casing hides the roadevery property on every layer can be replaced by an expression

The symbol model, in the order you will use it

Three classes carry almost all of the work, and knowing which one you are holding removes most of the confusion around the API.

QgsSymbol is abstract; you always hold one of QgsMarkerSymbol, QgsLineSymbol or QgsFillSymbol, matched to the layer's geometry type. Each owns an ordered list of QgsSymbolLayer objects. A QgsSymbolLayer is where the drawing instructions actually live: QgsSimpleLineSymbolLayer knows about width and dash pattern, QgsSvgMarkerSymbolLayer knows about a path to an SVG file, QgsSimpleFillSymbolLayer knows about a brush style and a stroke.

from qgis.core import QgsProject, QgsLineSymbol

layer = QgsProject.instance().mapLayersByName("roads")[0]
symbol = layer.renderer().symbol()

print(type(symbol).__name__, symbol.symbolLayerCount())
for index, symbol_layer in enumerate(symbol.symbolLayers()):
    print(index, symbol_layer.layerType(), symbol_layer.color().name())

Breakdown: renderer().symbol() only exists on a single-symbol renderer; a categorized or graduated renderer exposes symbols(QgsRenderContext()) or per-category symbol() instead, which is the first thing to check when this line raises AttributeError. layerType() returns the registry string — SimpleLine, SvgMarker, GeometryGenerator — and that string is the same one you pass when creating a layer from a properties dictionary, so printing it on a symbol you built in the GUI is the quickest way to learn the name of something you liked.

Because the symbol is retrieved by reference from the renderer, changes made to it are live, but the canvas does not know. Any script that edits symbology finishes with layer.triggerRepaint(), and any script that also wants the legend to catch up calls iface.layerTreeView().refreshLayerSymbology(layer.id()).

Composing a stack: casings, inner lines and hatches

The road-casing pattern is the canonical example, and it generalises to every "outline plus something" effect.

from qgis.core import QgsLineSymbol, QgsSimpleLineSymbolLayer
from qgis.PyQt.QtGui import QColor
from qgis.PyQt.QtCore import Qt

symbol = QgsLineSymbol()
symbol.deleteSymbolLayer(0)                       # drop the default layer

casing = QgsSimpleLineSymbolLayer(QColor("#3f3f46"))
casing.setWidth(1.6)
casing.setPenCapStyle(Qt.RoundCap)
casing.setPenJoinStyle(Qt.RoundJoin)

fill = QgsSimpleLineSymbolLayer(QColor("#fbbf24"))
fill.setWidth(1.0)
fill.setPenCapStyle(Qt.RoundCap)
fill.setPenJoinStyle(Qt.RoundJoin)

symbol.appendSymbolLayer(casing)
symbol.appendSymbolLayer(fill)
layer.renderer().setSymbol(symbol)
layer.triggerRepaint()

Breakdown: A freshly constructed QgsLineSymbol already contains one simple line layer, so the first move is almost always deleteSymbolLayer(0) — otherwise the default blue line sits underneath everything you add and shows at the joins. Round caps and joins on both layers matter more than they look: with the default flat caps, the casing stops exactly where the geometry stops and the bright fill pokes out past it at every segment end.

Widths are in millimetres by default. setOutputUnit(QgsUnitTypes.RenderMetersInMapUnits) switches a symbol layer to ground units, which is what you want when a road's width should shrink as you zoom out and stay true to its real width — and what you do not want for a hairline that must stay legible at every scale.

Why a casing needs round joinsThe upper example draws a single yellow line. The lower example draws a dark casing first and a narrower yellow line on top. At the corner the flat-capped version leaves the bright line protruding past the casing, while the round-joined version keeps the casing enclosing the fill all the way round the bend.The stack is only convincing if the caps and joins agreeone layer, flat capsno casing — the line has no edge to read againstcasing + fill, round joinsthe dark layer encloses the bright one at the bendindex 0 is painted first, so the casing must be added before the fill

Fills work the same way. A hatched fill under a solid outline is a QgsLinePatternFillSymbolLayer at index 0 and a QgsSimpleFillSymbolLayer with Qt.NoBrush on top, so the outline is crisp and the hatch never overruns it. The full recipe for stacking symbol layers walks through both cases with the property dictionaries.

Markers beyond the circle

QgsMarkerSymbol accepts several kinds of marker layer, and the choice is mostly about where the artwork comes from.

QgsSimpleMarkerSymbolLayer draws from a fixed set of shapes and is the fastest by a wide margin. QgsSvgMarkerSymbolLayer reads an SVG from disk or from the QGIS SVG search paths; if the SVG uses the QGIS parameter convention it can be recoloured and re-stroked per feature. QgsFontMarkerSymbolLayer renders one character from an installed font, which is how icon fonts get onto a map without shipping a directory of files. QgsRasterMarkerSymbolLayer places a PNG, and is the right answer only when the artwork genuinely is a photograph or a logo that must not be re-drawn.

from qgis.core import QgsMarkerSymbol

symbol = QgsMarkerSymbol.createSimple({
    "name": "circle",
    "color": "#2563eb",
    "outline_color": "#1e40af",
    "outline_width": "0.4",
    "size": "3",
})

Breakdown: createSimple() takes the same property dictionary the symbol layer's properties() method returns, so the fastest way to build one of these is to style a layer in the GUI, read the dictionary back in the console, and paste it into the script. The keys are documented per symbol-layer class and are not consistent across them — a marker uses outline_color, a fill uses outline_color too, but a simple line calls the same idea line_color.

Sizing and rotation are where markers earn their keep. Both accept a data-defined override, so a marker whose size encodes a value is a two-line change rather than a new renderer, and a marker whose rotation follows a bearing field turns a point layer into a flow map.

Data-defined overrides: the property that is really an expression

Every symbol-layer property is addressable by a property key, and any of them can be replaced with an expression evaluated per feature. This is the mechanism behind data-defined size, colour, offset, rotation and dash pattern.

from qgis.core import QgsProperty, QgsSymbolLayer

marker = symbol.symbolLayer(0)
marker.setDataDefinedProperty(
    QgsSymbolLayer.PropertySize,
    QgsProperty.fromExpression("coalesce(sqrt(\"population\") / 40, 1)"),
)
marker.setDataDefinedProperty(
    QgsSymbolLayer.PropertyFillColor,
    QgsProperty.fromExpression(
        "case when \"status\" = 'closed' then '#b91c1c' else '#15803d' end"
    ),
)

Breakdown: QgsProperty.fromExpression() compiles a QGIS expression once and evaluates it per feature during rendering. coalesce() around the size expression is not decoration — a null population would otherwise produce a null size and QGIS falls back to the static value silently, so half your markers come out the wrong size with no error anywhere. QgsProperty.fromField("size_mm") is the cheaper form when the value is already a column and needs no arithmetic.

The property enum names differ between symbol-layer families, and they moved in QGIS 3.36 to a scoped QgsSymbolLayer.Property.Size form with the old flat names kept as aliases. Code that has to run on both writes the flat name; code pinned to 3.36+ can use the scoped one.

Where a data-defined override takes effectDuring rendering, each feature reaches the symbol layer. If a property has no override the stored static value is used. If an override is set, the expression is evaluated against that feature and its result is used instead for that feature only, so one symbol produces many appearances.One symbol, a different answer for every featurefeaturepopulation = 82 000status = opensymbol layer propertysize = 3.0 (stored)override set? yesexpression evaluatedsqrt(population) / 40= 7.2 mm this featurewhat the reader seescloseda null attribute yields a null result and silently falls back to the stored value

Geometry generators: drawing something the feature is not

A QgsGeometryGeneratorSymbolLayer replaces the feature's geometry with the result of an expression before drawing. The feature stays a polygon in the data; the symbol layer draws its centroid, or its bounding box, or a buffer around it, or a line from it to somewhere else.

This solves problems that otherwise need a whole derived layer: a halo around selected parcels, a leader line from a point to its label anchor, an arrow at the end of every route, a shadow offset a couple of millimetres from every building. Because the generator is a symbol layer like any other, it stacks with the ordinary ones and it obeys the same data-defined overrides. The dedicated geometry generator guide covers the expression forms and the performance cliff to avoid.

Blend modes, opacity and draw effects

Two symbols overlapping is a compositing question, and QGIS exposes it at three levels: the layer, the symbol, and the individual symbol layer. layer.setOpacity(0.6) fades the whole rendered layer after it is drawn, which is different from setting every symbol's colour to 60 percent alpha — the former will not let a polygon's own overlapping parts show through itself, the latter will. symbol.setOpacity() and QgsSymbolLayer.setRenderingPass() control the same idea further down.

Blend modes turn overlapping fills into something readable: Multiply darkens where things overlap and is the standard choice for shaded relief under a thematic fill, Screen lightens, and Darken keeps whichever is already darker. Setting opacity and blend modes from Python covers the enum values and the export caveat, which is real: some blend modes cannot be represented in PDF or SVG and QGIS rasterises the layer to keep the appearance, quietly turning a vector export into a partly raster one.

Placing markers along lines and inside polygons

Three symbol-layer types exist purely to place a sub-symbol somewhere derived from the geometry, and they are the reason a lot of maps look hand-made.

QgsMarkerLineSymbolLayer repeats a marker along a line at an interval, or places one at the first vertex, the last vertex, every vertex, or the curve point. Arrowheads on a route, ticks on a contour, chevrons on a one-way street and the dots on a ferry line are all this one class with a different placement. QgsPointPatternFillSymbolLayer tiles a marker across a polygon on a regular grid — the standard treatment for marsh, orchard and quarry fills. QgsCentroidFillSymbolLayer places a single marker at the polygon's centroid, which is how a polygon layer gets a point-like label anchor without a second layer.

from qgis.core import (
    QgsLineSymbol, QgsMarkerLineSymbolLayer, QgsMarkerSymbol,
    QgsSimpleMarkerSymbolLayer, QgsSimpleMarkerSymbolLayerBase,
)
from qgis.PyQt.QtGui import QColor

arrow = QgsSimpleMarkerSymbolLayer(QgsSimpleMarkerSymbolLayerBase.Triangle)
arrow.setColor(QColor("#0f766e"))
arrow.setSize(3.0)
arrow.setAngle(90)

marker_line = QgsMarkerLineSymbolLayer()
marker_line.setPlacement(QgsMarkerLineSymbolLayer.Interval)
marker_line.setInterval(8.0)
marker_line.setRotateMarker(True)
marker_line.setSubSymbol(QgsMarkerSymbol([arrow]))

symbol = QgsLineSymbol()
symbol.appendSymbolLayer(marker_line)

Breakdown: setSubSymbol() takes a whole symbol, not a symbol layer — which is why the triangle is wrapped in QgsMarkerSymbol([arrow]). That nesting is the part people trip over, and it is also what makes the class powerful: the sub-symbol can itself be a stack, so an arrow with a casing is possible. setRotateMarker(True) aligns each marker to the local direction of the line, without which every triangle points the same way regardless of where the road goes. setInterval() is in the symbol layer's output unit, so switching to map units makes the spacing a real-world distance instead of a paper one.

Placement is an enum worth knowing by name: Interval, Vertex, FirstVertex, LastVertex, CentralPoint and CurvePoint. A single arrowhead at the end of every route is LastVertex with setRotateMarker(True), and it needs no interval at all.

Scale-dependent symbology

A symbol that works at 1:5 000 rarely works at 1:250 000, and QGIS gives you three ways to handle that without maintaining two layers.

The blunt instrument is scale-based visibility on the layer, which simply stops drawing it outside a range. Finer than that, a rule-based renderer attaches a scale range to each rule, so the same layer can draw thin casings when zoomed in and plain lines when zoomed out. Finest of all, a data-defined override can read the current scale directly, because @map_scale is available inside the expression during rendering:

from qgis.core import QgsProperty, QgsSymbolLayer

line = symbol.symbolLayer(0)
line.setDataDefinedProperty(
    QgsSymbolLayer.PropertyStrokeWidth,
    QgsProperty.fromExpression("scale_linear(@map_scale, 5000, 250000, 1.6, 0.3)"),
)

Breakdown: scale_linear() maps the current map scale onto a width range and clamps at both ends, so the line tapers smoothly as the map zooms out rather than jumping at a rule boundary. Because the expression re-evaluates on every repaint, this costs one expression evaluation per feature per redraw — cheap for a few thousand features, noticeable for a few hundred thousand, and a good reason to prefer rule-based scale ranges on large layers.

What makes symbology slow

Rendering cost scales with the number of symbol layers multiplied by the number of features, and a few choices dominate.

SVG markers are re-parsed and re-rasterised per unique size and colour combination; a data-defined colour on an SVG marker across fifty thousand points is the single most reliable way to make a canvas unusable. Font markers are much cheaper because the glyph is cached. Geometry generators run an expression per feature per draw, and a generator that buffers is doing real geometry work at every repaint. Pattern fills at small line spacings generate enormous numbers of primitives.

The practical rules: keep the stack as short as the design allows, prefer simple markers where the shape is a shape, cache what does not change by baking derived geometry into a real field instead of a generator, and check the effect with the profiling techniques rather than guessing which layer is expensive.

Key takeaways

  • A symbol is an ordered stack of symbol layers painted from index 0 upward; the casing must be added before the fill, not after.
  • A freshly built symbol already holds one default layer — delete it before appending, or it shows through at the joins.
  • Property dictionaries from symbolLayer.properties() are the fastest bridge between a style you built in the GUI and a script that reproduces it.
  • Any symbol-layer property can be replaced with a QgsProperty expression; wrap numeric ones in coalesce() so nulls do not silently fall back.
  • Geometry generators draw something derived from the feature without creating a derived layer, at the cost of an expression per feature per repaint.
  • Blend modes are set per layer, per symbol or per symbol layer, and some of them force rasterisation on PDF and SVG export.
  • Every symbology change needs layer.triggerRepaint(), and a legend refresh if the change alters what the legend should show.

Frequently Asked Questions

Why does my new symbol still show a blue line underneath it?QgsLineSymbol() and its siblings are constructed with one default symbol layer already present. Call deleteSymbolLayer(0) before appending yours, or build the symbol with QgsLineSymbol.createSimple({...}) and edit layer 0 in place instead of appending.

How do I find the property keys for a symbol layer type? Style one layer the way you want in the GUI, then in the Python console run layer.renderer().symbol().symbolLayer(0).properties(). The dictionary it returns is exactly what createSimple() and QgsSymbolLayerRegistry accept, so there is no need to look the keys up.

Can a categorized renderer use a stacked symbol? Yes. Each category holds a full symbol, so build the stack once and clone it per category with symbol.clone(), changing only the colour of the layer that should vary. Cloning matters — assigning the same symbol object to several categories makes them share state.

What is the difference between layer opacity and symbol opacity? Layer opacity is applied to the finished raster of the whole layer, so a polygon overlapping itself still looks solid. Symbol or colour alpha is applied per drawn primitive, so overlaps accumulate and read darker. Choose layer opacity for a wash over a basemap and colour alpha when the overlap itself is the information.

Do symbol changes need the layer to be in edit mode? No. Symbology lives in the layer's style, not in the data, so no edit session or commit is involved. Persist it with layer.saveNamedStyle() or by saving a QML, and remember that saving the project stores the style inside the project file too.

Why did my SVG marker come out black? The SVG does not use the QGIS parameter convention, so param(fill) and param(outline) are absent and QGIS cannot recolour it. Either edit the SVG to use those placeholders or accept the file's own colours and stop setting setFillColor().