Programmatic Layer Styling in PyQGIS
Styling a layer by hand in the QGIS symbology dialog is fine for a one-off map, but it does not scale. When you publish twenty thematic layers a week, regenerate a basemap nightly, or ship a plugin that has to render its outputs consistently, the symbology has to be expressed as code. This guide, part of the PyQGIS Cartography & Data Visualization pillar, covers the renderer and symbol classes that let you set every visual property of a vector layer programmatically — from a single fill colour to per-feature data-defined sizes — and then push those changes to the canvas and the legend.
The focus here is the single-symbol case: every feature in the layer drawn with one symbol. That is the foundation. Once you understand how QgsSingleSymbolRenderer, QgsSymbol, and the symbol-layer stack fit together, the class-driven renderers covered in Graduated & Categorized Renderers become a small step up, because they reuse the exact same symbol objects you build here.
Prerequisites
- QGIS 3.34 LTR (bundles Python 3.12) running scripts from the Python Console or a standalone application.
- A loaded vector layer — points, lines, or polygons — that is valid (
layer.isValid()returnsTrue). - Imports from
qgis.corefor symbology andqgis.PyQt.QtGuiforQColor. - Familiarity with the renderer concept: a layer holds exactly one renderer, and the renderer decides which symbol draws each feature.
- Optional: the QGIS GUI open, so you can watch the canvas and legend update as you run code.
The symbology object model
Before writing any styling code, it helps to hold the hierarchy in your head. A layer owns a renderer. A single-symbol renderer owns one symbol. A symbol owns an ordered stack of symbol layers, and each symbol layer carries the concrete drawing properties — colour, stroke, size, offset. You almost never replace the whole chain; you reach into it, change a property, and repaint.
Why so many levels? Because each one is a genuine extension point. The renderer decides which features get which symbol — a single-symbol renderer says "all of them get this one", a categorized renderer branches on an attribute. The symbol decides the overall colour, opacity, and (for markers) angle. The symbol-layer stack lets a single symbol composite several passes: a fill plus a hatch, a line plus a casing, a marker plus a halo. Understanding which level owns a property is the whole skill — set opacity on the symbol, set stroke style on the symbol layer, set the field mapping on the renderer.
Building and applying a single-symbol renderer
The cleanest way to style a layer from scratch is to construct a fresh symbol with QgsSymbol.defaultSymbol(), which returns the correct subclass (marker, line, or fill) for the layer's geometry type, then wrap it in a QgsSingleSymbolRenderer.
from qgis.core import (
QgsProject, QgsSymbol, QgsSingleSymbolRenderer, QgsWkbTypes,
)
from qgis.PyQt.QtGui import QColor
layer = QgsProject.instance().mapLayersByName("parcels")[0]
# defaultSymbol returns a QgsFillSymbol / QgsLineSymbol / QgsMarkerSymbol
# that matches the layer geometry automatically.
symbol = QgsSymbol.defaultSymbol(layer.geometryType())
symbol.setColor(QColor("#2563eb"))
symbol.setOpacity(0.85)
renderer = QgsSingleSymbolRenderer(symbol)
layer.setRenderer(renderer)
layer.triggerRepaint()
Breakdown: QgsSymbol.defaultSymbol() saves you from guessing the geometry type — pass layer.geometryType() and you get a symbol you can draw with immediately. setColor() on the symbol cascades to all of its symbol layers, while setOpacity() (a float from 0–1) controls layer-wide transparency without touching the per-colour alpha channel. Calling setRenderer() replaces whatever renderer existed; triggerRepaint() schedules a redraw. For the legend to update too, see the apply step below.
Reaching into fill, line, and marker symbol layers
setColor() on the symbol is a convenience that fans out to every symbol layer. For finer control — a distinct outline, a dashed stroke, a specific marker shape — you address the symbol layer directly with symbolLayer(0). The available setters depend on the geometry.
from qgis.core import QgsProject, Qt
from qgis.PyQt.QtGui import QColor
layer = QgsProject.instance().mapLayersByName("parcels")[0]
symbol = layer.renderer().symbol()
sl = symbol.symbolLayer(0) # the bottom-most symbol layer
# Polygon (QgsSimpleFillSymbolLayer):
sl.setColor(QColor(34, 197, 94, 160)) # RGBA fill
sl.setStrokeColor(QColor("#15803d")) # outline colour
sl.setStrokeWidth(0.6) # in symbol units (mm)
sl.setStrokeStyle(Qt.DashLine) # dashed outline
layer.triggerRepaint()
Breakdown: symbolLayer(0) returns the concrete drawing layer — QgsSimpleFillSymbolLayer for polygons, QgsSimpleLineSymbolLayer for lines, QgsSimpleMarkerSymbolLayer for points. For lines you would call setWidth() and setPenStyle(); for markers setSize(), setShape(), and setStrokeColor(). The four-argument QColor(r, g, b, a) sets the alpha per colour, which stacks with the symbol-level setOpacity(). Changing only the symbol layer keeps the rest of the stack intact, which matters when a symbol has several layers (for example a fill plus a hatched overlay). Setting just the symbol colour without touching the outline is the most common single task — the focused recipe Set Vector Layer Symbol Color in PyQGIS walks through it for every geometry type.
Stacking symbol layers for richer symbols
A symbol is a stack. You can append symbol layers to draw, say, a casing under a road or a halo behind a point. Layers draw bottom-to-top, so index 0 is painted first.
from qgis.core import (
QgsProject, QgsSimpleLineSymbolLayer,
)
from qgis.PyQt.QtGui import QColor
layer = QgsProject.instance().mapLayersByName("roads")[0]
symbol = layer.renderer().symbol()
# Bottom layer: a wide dark casing.
casing = QgsSimpleLineSymbolLayer.create({})
casing.setColor(QColor("#17211d"))
casing.setWidth(1.4)
# Existing top layer: a thin bright centre line.
symbol.symbolLayer(0).setColor(QColor("#f6f3ea"))
symbol.symbolLayer(0).setWidth(0.7)
# Insert the casing beneath the existing line.
symbol.insertSymbolLayer(0, casing)
layer.triggerRepaint()
Breakdown: insertSymbolLayer(0, casing) places the casing at the bottom of the stack so the centre line renders on top of it, producing a cased-road effect. appendSymbolLayer() adds to the top instead, and deleteSymbolLayer(index) removes one. Each symbol layer is independent, so you can give the casing a dashed style while the centre stays solid. Building symbols this way is exactly how QGIS's own preset styles work under the hood.
Data-defined overrides
The real power of programmatic styling is binding a symbol property to an expression or attribute, so it varies per feature without switching to a categorized renderer. This is done with QgsProperty and the symbol layer's setDataDefinedProperty().
from qgis.core import (
QgsProject, QgsProperty, QgsSymbolLayer,
)
layer = QgsProject.instance().mapLayersByName("cities")[0]
sl = layer.renderer().symbol().symbolLayer(0)
# Scale marker size by population (sqrt for perceptual area scaling).
size_expr = QgsProperty.fromExpression("sqrt(\"population\") / 30")
sl.setDataDefinedProperty(QgsSymbolLayer.PropertySize, size_expr)
# Drive fill colour from a category field using a CASE expression.
color_expr = QgsProperty.fromExpression(
"CASE WHEN \"capital\" = 1 THEN '#b45309' ELSE '#2563eb' END"
)
sl.setDataDefinedProperty(QgsSymbolLayer.PropertyFillColor, color_expr)
layer.triggerRepaint()
Breakdown: QgsProperty.fromExpression() compiles a QGIS expression once; setDataDefinedProperty() binds it to a named property enum. PropertySize, PropertyFillColor, PropertyStrokeColor, PropertyStrokeWidth, and PropertyAngle are the most-used keys. Because the expression evaluates per feature at draw time, you keep a single-symbol renderer while still varying appearance — ideal when the variation is continuous (size from a count) rather than a small set of classes. For genuinely discrete classes you would reach for the Graduated & Categorized Renderers cluster instead.
Data-defined properties also accept a bare field name rather than a full expression, which is faster to write when the attribute already holds the value you want. QgsProperty.fromField("rotation") binds the symbol's angle directly to a rotation column, and QgsProperty.fromValue(0.4) pins a static override that ignores attributes entirely. The three constructors — fromExpression, fromField, and fromValue — cover every override scenario, and you can inspect what is bound on a property with prop.propertyType() when debugging an unexpected render.
Scaling diameters, widths, and units
Symbol sizes are expressed in symbol units, and choosing the right unit is the difference between a map that holds up at every zoom level and one that turns into blobs. The default unit is millimetres (a fixed paper size), but you frequently want map units (a fixed ground size) or pixels.
from qgis.core import QgsProject, QgsUnitTypes
layer = QgsProject.instance().mapLayersByName("wells")[0]
sl = layer.renderer().symbol().symbolLayer(0)
sl.setSize(4.0)
sl.setSizeUnit(QgsUnitTypes.RenderMillimeters) # fixed on paper
# Switch to a ground-referenced radius (e.g. a 250 m buffer marker):
sl.setSize(250.0)
sl.setSizeUnit(QgsUnitTypes.RenderMapUnits) # scales with zoom
layer.triggerRepaint()
Breakdown: setSize() sets the numeric value while setSizeUnit() decides what that number means. RenderMillimeters keeps a marker the same physical size on a printed map regardless of scale, which is right for legends and point symbols. RenderMapUnits ties the size to ground distance, so a 250 m marker shrinks as you zoom out — appropriate when the symbol should represent a real-world extent. The same *Unit setters exist for stroke width (setStrokeWidthUnit) and offsets, and mixing units within one symbol is allowed.
Applying changes: repaint, legend, and persistence
Editing renderer objects mutates the layer in memory, but the canvas and the Layers panel do not know until you tell them. Two calls cover the common cases, and a third persists the style to disk.
from qgis.utils import iface
layer = QgsProject.instance().mapLayersByName("parcels")[0]
# 1. Redraw the map canvas.
layer.triggerRepaint()
# 2. Refresh the legend / Layers panel symbol swatch.
if iface is not None:
iface.layerTreeView().refreshLayerSymbology(layer.id())
# 3. Persist the style alongside the data source (writes a .qml sidecar).
layer.saveNamedStyle(layer.source().split("|")[0].replace(".shp", ".qml"))
Breakdown: triggerRepaint() only updates the canvas; the legend swatch is owned by the layer tree, so refreshLayerSymbology() is the separate call that fixes a stale legend. iface is None in standalone scripts, hence the guard. saveNamedStyle() writes a QML style file so the symbology reloads automatically next time the layer is opened — the durable alternative to re-running your script. You can also store styles inside a GeoPackage or a PostGIS table with saveStyleToDatabase() when a sidecar file is not practical.
A complementary technique is to serialise a symbol to a portable QML symbol file rather than a full layer style, which lets you build a symbol once and reuse it across many layers. The single-line task of changing just the colour is documented separately in Set Vector Layer Symbol Color in PyQGIS, while raster layers follow an entirely different rendering path covered in Apply a Color Ramp to a Raster in PyQGIS.
Cloning and reusing symbols across layers
Symbols are mutable objects, so assigning the same symbol instance to two renderers couples them — editing one changes both. When you want a consistent house style applied to several layers without that coupling, clone the symbol for each.
from qgis.core import (
QgsProject, QgsSymbol, QgsSingleSymbolRenderer,
)
from qgis.PyQt.QtGui import QColor
# Build a template symbol once.
template = QgsSymbol.defaultSymbol(QgsProject.instance()
.mapLayersByName("a")[0].geometryType())
template.setColor(QColor("#0f766e"))
template.symbolLayer(0).setStrokeWidth(0.4)
for name in ("roads_major", "roads_minor", "roads_track"):
layer = QgsProject.instance().mapLayersByName(name)[0]
layer.setRenderer(QgsSingleSymbolRenderer(template.clone()))
layer.triggerRepaint()
Breakdown: template.clone() produces an independent deep copy, so each layer owns its own symbol and a later tweak to one road class does not leak into the others. Passing template directly without cloning would share one object across all three layers — occasionally what you want, but rarely. This pattern centralises a style definition in one place while keeping the layers independent, which is the foundation of any scripted house-style system.
Blend modes and feature render order
Two further controls shape how a styled layer reads against its neighbours: the layer's blend mode and the order features draw in. Both live on the layer or its renderer rather than the symbol, and both are commonly set in code for thematic overlays.
from qgis.core import (
QgsProject, QgsFeatureRenderer, QgsPainting,
)
layer = QgsProject.instance().mapLayersByName("heat_overlay")[0]
# Multiply blend lets an overlay darken the basemap beneath it.
layer.setBlendMode(QgsPainting.BlendMultiply)
# Draw larger features first so small ones stay visible on top.
order = QgsFeatureRenderer.OrderBy()
from qgis.core import QgsFeatureRequest
clause = QgsFeatureRequest.OrderByClause("\"area\"", ascending=False)
order.append(clause)
renderer = layer.renderer()
renderer.setOrderBy(order)
renderer.setOrderByEnabled(True)
layer.triggerRepaint()
Breakdown: setBlendMode() controls how the layer composites with whatever is below it on the canvas; BlendMultiply is the standard choice for shading overlays because it darkens rather than replaces. The OrderBy machinery tells the renderer the sequence to draw features in — sorting by descending area paints big polygons first so small ones land on top and remain clickable and visible. setOrderByEnabled(True) is required, since an order-by clause is ignored unless explicitly switched on. These are layer-level concerns that complement, rather than replace, the symbol-level styling above.
Inspecting an existing renderer before you change it
Production scripts rarely start from a blank layer. More often you receive a project where someone already configured symbology, and you need to read what is there before deciding whether to adjust or replace it. Defensive inspection prevents the classic AttributeError from calling .symbol() on a renderer type that does not have one.
from qgis.core import QgsProject, QgsSingleSymbolRenderer
layer = QgsProject.instance().mapLayersByName("parcels")[0]
renderer = layer.renderer()
print(f"Renderer type: {renderer.type()}") # 'singleSymbol', 'categorizedSymbol', ...
if isinstance(renderer, QgsSingleSymbolRenderer):
symbol = renderer.symbol()
print(f"Symbol type: {symbol.symbolType()}")
print(f"Opacity: {symbol.opacity()}")
print(f"Layer count: {symbol.symbolLayerCount()}")
for i in range(symbol.symbolLayerCount()):
sl = symbol.symbolLayer(i)
print(f" [{i}] {sl.layerType()} color={sl.color().name()}")
else:
print("Not a single-symbol renderer; resetting to default.")
new_symbol = QgsSymbol.defaultSymbol(layer.geometryType())
layer.setRenderer(QgsSingleSymbolRenderer(new_symbol))
Breakdown: renderer.type() returns a string identifier, while isinstance() against the concrete class is the safe gate before calling .symbol(). Once inside, symbolType(), opacity(), and symbolLayerCount() describe the symbol, and iterating symbolLayer(i) enumerates each drawing layer with its layerType() and current color(). QColor.name() prints the hex form, which is handy for logging. The else branch shows the reset-to-default fallback, giving any incoming layer a predictable single-symbol baseline before you apply your styling.
Working with the expression context
Data-defined overrides evaluate against an expression context, which supplies the variables and fields the expression can see. For most layer-bound expressions QGIS builds this context automatically at draw time, but when you evaluate a property yourself — to validate it, or to preview a value — you assemble the context explicitly.
from qgis.core import (
QgsProject, QgsProperty, QgsExpressionContext,
QgsExpressionContextUtils,
)
layer = QgsProject.instance().mapLayersByName("cities")[0]
prop = QgsProperty.fromExpression("sqrt(\"population\") / 30")
context = QgsExpressionContext()
context.appendScopes(
QgsExpressionContextUtils.globalProjectLayerScopes(layer)
)
feature = next(layer.getFeatures())
context.setFeature(feature)
value, ok = prop.valueAsDouble(context, 0.0)
print(f"Evaluated size for first feature: {value} (ok={ok})")
Breakdown: QgsExpressionContext is the bag of scopes (global, project, layer, feature) an expression resolves against. globalProjectLayerScopes(layer) is a one-call helper that stacks the standard scopes for a given layer. setFeature() makes the feature's attributes available so "population" resolves. valueAsDouble() evaluates the property and returns a (value, ok) pair, letting you confirm an override produces sane numbers before you trust it on a full render. This is the same machinery QGIS runs internally for every feature, exposed for testing.
Compatibility note
| QGIS version | Python | Styling API notes |
|---|---|---|
| 3.28 LTR | 3.9 | Full single-symbol and data-defined API present; QgsSymbolLayer.Property* enums available. |
| 3.34 LTR | 3.12 | Baseline for this guide; all examples run unchanged. |
| 3.40 / 3.44 | 3.12 | Enum names are gradually moving under scoped types (e.g. QgsSymbolLayer.Property.PropertySize); the flat names still resolve via compatibility aliases. |
The symbology object model has been stable since the QGIS 3.0 series, so scripts written against 3.34 run on both the older 3.28 LTR and the newer 3.40/3.44 line with no changes. The only churn is cosmetic enum scoping in 3.40+, which does not break the flat QgsSymbolLayer.PropertySize form used above.
Key Takeaways
- A layer holds one renderer; a single-symbol renderer holds one symbol; a symbol holds a stack of symbol layers. Edit the level that matches your intent.
- Use
QgsSymbol.defaultSymbol(layer.geometryType())to build a correctly typed symbol without branching on geometry. symbol.setColor()cascades to all symbol layers;symbolLayer(0).setColor()/setStrokeColor()give per-layer control.setDataDefinedProperty()withQgsProperty.fromExpression()varies a property per feature while keeping a single symbol.- Always pair
triggerRepaint()withrefreshLayerSymbology()for a correct canvas and legend, and usesaveNamedStyle()to persist.
Frequently Asked Questions
Why does my legend still show the old symbol after I change the colour?triggerRepaint() only updates the map canvas. The Layers panel swatch is rendered by the layer tree, so you must additionally call iface.layerTreeView().refreshLayerSymbology(layer.id()). In a standalone script with no GUI, there is no legend to refresh and the call can be skipped.
What is the difference between symbol.setColor() and symbolLayer(0).setColor()?symbol.setColor() is a convenience that pushes the colour to every symbol layer in the stack. symbolLayer(0).setColor() targets a single layer, which is what you want when a symbol has multiple layers or when you need to change the fill without touching the outline.
Can I change styling without a categorized or graduated renderer? Yes. Data-defined overrides let a single-symbol renderer vary size, colour, rotation, and width per feature via expressions. Reach for categorized or graduated renderers only when you need discrete legend classes rather than a continuous mapping.
How do I keep the style after the script finishes?
Call layer.saveNamedStyle("path/to/style.qml") to write a QML sidecar that QGIS loads automatically with the layer, or saveStyleToDatabase() to embed it in a GeoPackage or PostGIS table. Re-running the script is otherwise required because renderer edits live only in memory.
Does setOpacity() conflict with the alpha in QColor(r, g, b, a)?
They multiply. setOpacity(0.5) halves the opacity of the whole symbol, and a per-colour alpha further reduces a single layer. Use symbol opacity for overall transparency and the colour alpha for fine-tuning one layer relative to the rest.