Layer Styling and Symbology Automation in PyQGIS

Automating QGIS symbology from Python: the renderer and symbol object model, building categorized and graduated renderers from data, data-defined properties,…

Styling is the part of QGIS most people only ever do by clicking, and it is also the part that benefits most from being scripted. A style applied by hand to forty layers is forty chances to pick a slightly different green; a style applied from code is one rule applied forty times. This page, part of the PyQGIS Core Architecture & Data Handling guide, covers the renderer and symbol object model, building categorized and graduated renderers from data, data-defined properties, and moving styles between layers and machines with QML.

The goal throughout is reproducibility: a script that produces the same cartography from the same data every time it runs, in the desktop and in a headless export alike.

Prerequisites Checklist

  • QGIS 3.28 LTR or newer. The renderer classes below are stable across 3.x, but QgsClassificationMethod subclasses arrived in 3.10 and are used here.
  • A vector layer with at least one categorical and one numeric field. Both renderer families are covered, and each needs different input.
  • Python 3.9+ for the type hints in every sample.
  • Familiarity with the expression engine. Data-defined properties are expressions, and the attribute data and expression engine guide covers the context rules they follow.
  • Somewhere to look at the result. For headless work, that means a layout export — see automating print layouts and map export.

How the Renderer Chain Works

A QgsVectorLayer holds exactly one renderer. When the map is drawn, the renderer is asked, for each feature, which symbol should draw it. A symbol is a stack of symbol layers, each of which paints one pass — a fill plus an outline, or a line plus a marker at its vertices. Each symbol layer exposes properties: colour, width, offset, rotation.

The objects between a layer and a drawn feature Four bands showing the renderer chain: the layer holds one renderer, the renderer decides which symbol applies to a feature, the symbol holds one or more symbol layers, and each symbol layer carries the properties that are actually painted. QgsVectorLayer exactly one renderer setRenderer() Renderer single / categorized graduated / rule-based picks a symbol per feature QgsSymbol marker, line, fill by geometry type one or more layers stacked Symbol layer colour, width, offset the painted properties data-defined expression per property Almost every styling question is really a question about which of these four levels the change belongs at.

That four-level structure explains most of the API. To change every feature’s colour you reach for the symbol; to change which features get which colour you reach for the renderer; to add an outline you add a symbol layer; and to vary a property per feature without adding classes you attach a data-defined override to that property.

Every level is also cloneable, which is the mechanism behind almost all styling automation: build one symbol, clone it per class, adjust the clone, and hand the set to a renderer.

Which renderer the data calls for A decision tree on the attribute driving the style: a single symbol when nothing varies, a categorized renderer for a discrete attribute, a graduated renderer for a continuous one, and a rule-based renderer when the condition combines several fields or scales. What decides how a feature looks? nothing QgsSingleSymbolRenderer one symbol a discrete field Categorized one class per value a number Graduated classified ranges a condition Rule-based expressions and scales

Step-by-Step Implementation

Step 1 — Build a symbol for the geometry type

Symbols are geometry-specific. QgsSymbol.defaultSymbol() returns the right subclass for a layer’s geometry, which keeps the code polymorphic across point, line and polygon layers.

python
from qgis.core import QgsSymbol, QgsVectorLayer
from qgis.PyQt.QtGui import QColor


def base_symbol(layer: QgsVectorLayer, colour: str, width: float = 0.26) -> QgsSymbol:
    """Return a default symbol for `layer`'s geometry type, tinted to `colour`."""
    symbol = QgsSymbol.defaultSymbol(layer.geometryType())
    symbol.setColor(QColor(colour))
    if symbol.symbolLayerCount():
        symbol.symbolLayer(0).setStrokeWidth(width)
    return symbol

Step 2 — Categorize by a discrete attribute

A categorized renderer is a list of QgsRendererCategory objects — value, symbol, label — plus the field name to read. Building the categories from the data itself, rather than a hard-coded list, is what makes the script survive a dataset that grows a new class.

python
from qgis.core import (QgsCategorizedSymbolRenderer, QgsRendererCategory,
                       QgsVectorLayer)


def categorize(layer: QgsVectorLayer, field: str, palette: dict[str, str]) -> None:
    """Style `layer` with one class per distinct value of `field`.

    Values missing from `palette` are drawn in grey and labelled, rather than
    silently dropped, so an unexpected class is visible on the map.
    """
    idx = layer.fields().indexFromName(field)
    if idx < 0:
        raise KeyError("no field %r on %r" % (field, layer.name()))

    categories = []
    for value in sorted(v for v in layer.uniqueValues(idx) if v is not None):
        colour = palette.get(str(value), "#9e9e9e")
        categories.append(QgsRendererCategory(value, base_symbol(layer, colour), str(value)))

    layer.setRenderer(QgsCategorizedSymbolRenderer(field, categories))
    layer.triggerRepaint()

uniqueValues() asks the provider for the distinct set, which on a database backend is a single SELECT DISTINCT rather than a full read. On a large layer this matters: the naive version that iterates every feature to collect values is doing the provider’s job for it.

Step 3 — Grade a numeric attribute

Graduated renderers classify a numeric field into ranges. The classification method is a separate object, which is what lets you swap equal-interval for quantiles without touching the rest of the code.

python
from qgis.core import (QgsClassificationQuantile, QgsGradientColorRamp,
                       QgsGraduatedSymbolRenderer, QgsStyle, QgsVectorLayer)


def graduate(layer: QgsVectorLayer, field: str, classes: int = 5,
             ramp_name: str = "Viridis") -> None:
    """Style `layer` with `classes` quantile ranges over `field`."""
    renderer = QgsGraduatedSymbolRenderer(field, [])
    renderer.setClassificationMethod(QgsClassificationQuantile())
    renderer.setSourceSymbol(base_symbol(layer, "#31688e"))

    ramp = QgsStyle.defaultStyle().colorRamp(ramp_name)
    if ramp is None:                       # the named ramp is not installed
        ramp = QgsGradientColorRamp()
    renderer.updateColorRamp(ramp)
    renderer.updateClasses(layer, classes)

    layer.setRenderer(renderer)
    layer.triggerRepaint()

The order of the last three calls is load-bearing. updateClasses() reads the data and computes the breaks; setting the ramp afterwards would leave the newly created classes uncoloured.

Step 4 — Persist the style

A renderer built in memory disappears with the session unless it is saved. There are two useful destinations: a QML sidecar next to the data, which QGIS loads automatically when the layer is opened, and the layer’s entry inside a project file.

python
from qgis.core import QgsVectorLayer


def save_style(layer: QgsVectorLayer, qml_path: str) -> None:
    """Write the layer's current style to `qml_path`."""
    message, ok = layer.saveNamedStyle(qml_path)
    if not ok:
        raise RuntimeError("could not save style: %s" % message)

saveNamedStyle() returns a message and a success flag in that order — a signature that is easy to unpack the wrong way round, and doing so produces code that silently ignores every failure.

Four ways to move a style between layers A grid of four style-transfer mechanisms — a QML sidecar, an SLD export, copying the renderer object, and the style manager database — showing what each preserves and where it is normally used. preserves used for QML file everything QGIS can express sharing between QGIS users SLD file the OGC subset only publishing to a map server clone the renderer everything, in memory scripting many layers style manager symbols, not renderers a reusable symbol library

Advanced Patterns

Data-defined properties instead of more classes

When a property varies continuously — marker size by population, line width by traffic volume — adding classes is the wrong tool. A data-defined override attaches an expression to one property of one symbol layer, and the expression is evaluated per feature.

python
from qgis.core import QgsProperty, QgsSymbolLayer, QgsVectorLayer


def size_by_field(layer: QgsVectorLayer, field: str, lo: float = 2.0,
                  hi: float = 12.0) -> None:
    """Scale marker size linearly with `field`, between `lo` and `hi` millimetres."""
    symbol_layer = layer.renderer().symbol().symbolLayer(0)
    expression = "scale_linear(\"%s\", minimum(\"%s\"), maximum(\"%s\"), %s, %s)" % (
        field, field, field, lo, hi)
    symbol_layer.setDataDefinedProperty(
        QgsSymbolLayer.PropertySize, QgsProperty.fromExpression(expression))
    layer.triggerRepaint()

The cost is real and worth knowing before you reach for it: every data-defined property is an expression evaluated once per feature per render pass.

Render cost of the same layer styled four ways A bar chart of the time to render a 200 000-feature polygon layer with a single symbol, a categorized renderer, a rule-based renderer with three rules, and a rule-based renderer whose rules each evaluate a data-defined expression. single symbol 340 ms categorized 470 ms rule-based, 3 rules 690 ms data-defined properties 1.85 s Indicative full-extent render times. A data-defined property is an expression evaluated per feature per property, which is why it dominates the moment it appears.

For a layer that is rendered repeatedly — a canvas the user pans around, or an atlas with three hundred pages — precomputing the value into a real column and classifying on that is usually the better trade.

Rule-based renderers for scale-dependent cartography

A rule-based renderer holds a tree of rules, each with an optional filter expression and an optional scale range. This is how one layer shows generalised polygons when zoomed out and labelled detail when zoomed in, without duplicating the layer.

python
from qgis.core import QgsRuleBasedRenderer, QgsVectorLayer


def scale_rules(layer: QgsVectorLayer) -> None:
    """Show only major roads above 1:100 000, everything below it."""
    root = QgsRuleBasedRenderer.Rule(None)

    major = QgsRuleBasedRenderer.Rule(base_symbol(layer, "#b71c1c"))
    major.setFilterExpression("\"class\" = 'major'")
    major.setLabel("Major roads")
    root.appendChild(major)

    minor = QgsRuleBasedRenderer.Rule(base_symbol(layer, "#757575"))
    minor.setFilterExpression("\"class\" <> 'major'")
    minor.setMaximumScale(100000)      # hidden when zoomed further out than 1:100 000
    minor.setLabel("Minor roads")
    root.appendChild(minor)

    layer.setRenderer(QgsRuleBasedRenderer(root))
    layer.triggerRepaint()

Note that setMaximumScale takes the larger denominator — QGIS scale arguments read backwards from how most people say them aloud, and mixing the two up produces a layer that is visible in exactly the wrong range.

Applying one style across a whole project

Because renderers clone cleanly, applying a house style to every matching layer is a short loop. Clone rather than share: assigning the same renderer object to two layers means a later change to one silently changes the other.

python
from qgis.core import QgsProject, QgsVectorLayer


def apply_house_style(source: QgsVectorLayer, name_prefix: str) -> int:
    """Copy `source`'s renderer onto every project layer whose name starts with the prefix."""
    styled = 0
    for layer in QgsProject.instance().mapLayers().values():
        if not isinstance(layer, QgsVectorLayer) or not layer.name().startswith(name_prefix):
            continue
        if layer.geometryType() != source.geometryType():
            continue                     # a fill renderer on a point layer draws nothing
        layer.setRenderer(source.renderer().clone())
        layer.triggerRepaint()
        styled += 1
    return styled

Keeping cartography reviewable

A style that only exists inside a project file is difficult to review and impossible to diff. The practical answer is to treat the styling script as the source of truth and the QML as build output: the script encodes the decisions — which field classifies, which ramp, how many classes, what the fallback colour is — and running it regenerates the style from scratch.

That has two consequences worth planning for. The first is that manual tweaks made in the QGIS interface are transient by design; anyone who adjusts a symbol in the designer must fold the change back into the script or lose it on the next run. Teams that skip this step end up with a script nobody trusts and a project file nobody can reproduce.

The second is that the script becomes the place to enforce house rules. A single helper that returns the organisation’s approved colours makes an off-palette map a code change rather than an accident, and a check that every categorized renderer has an explicit fallback stops unknown classes from silently disappearing off the map. Neither is possible when styling lives only in a binary project file.

Pitfalls and Debugging

  • Nothing changes on screen. setRenderer() alone does not repaint. Call layer.triggerRepaint(), and in a plugin also refresh the layer tree symbology so the legend matches what the map now shows.

  • A renderer shared between layers. setRenderer() takes ownership of the object. Passing the same instance to two layers gives one of them a renderer that has already been claimed, with results ranging from shared state to a crash. Always pass renderer.clone().

  • Empty classes after updateClasses(). The field is not numeric, or every value is NULL. A graduated renderer over a text column silently produces zero ranges and the layer disappears.

  • A colour ramp that is not installed. QgsStyle.defaultStyle().colorRamp(name) returns None for a ramp missing from the user’s profile — common on a fresh CI container. Always provide a fallback rather than passing None on.

  • QML written for a different QGIS version. A style file records the version that wrote it. Older QGIS reading a newer QML usually works, but silently drops symbol layer types it does not know, so the map looks subtly wrong rather than failing.

  • Scale arguments inverted. setMinimumScale and setMaximumScale both take denominators, and the minimum scale is the larger number. Getting them the wrong way round hides the layer precisely where it should appear.

Frequently Asked Questions

Why does my styling code work in the console but not in a script?

The usual cause is that the layer was never added to a project, or was garbage collected before rendering. A layer created in a local variable and styled but never registered has nothing holding it alive.

The second cause is missing repaint calls. In the console a subsequent action tends to trigger a refresh; in a headless export nothing does, so the layout renders whatever state the layer was in when the exporter read it.

Should I ship styles as QML or SLD?

QML if the consumer is QGIS, SLD if it is a map server. QML can express everything QGIS can draw; SLD is an OGC standard covering a substantially smaller subset, so exporting a complex QGIS style to SLD loses whatever the standard has no vocabulary for — usually the more elaborate symbol layer types and any data-defined properties.

When a style has to work in both places, design it against the SLD subset from the start rather than simplifying afterwards.

How do I style a layer that has not loaded yet?

You cannot, and it is worth being explicit about why: renderers are built against a geometry type and often against real attribute values, neither of which exist until the provider has opened the source. Check layer.isValid() before styling, and treat an invalid layer as a hard error rather than styling it anyway.

For deferred loading — a layer that arrives from a background task — apply the style in the completion callback, on the main thread, as described in running heavy geoprocessing in the background.

Can I read the current style rather than replace it?

Yes, and it is often the better move. layer.renderer() returns the live object, and its clone() gives you a copy you can modify without disturbing the layer. Modifying the live renderer in place works but skips the repaint, and it makes an undo impossible.

To capture a style as text for comparison or version control, saveNamedStyle() writes QML that diffs reasonably well — far better than trying to compare renderer objects.

Does styling affect processing results?

No. Renderers are a presentation concern; Processing algorithms read geometry and attributes and ignore symbology entirely. The one exception is anything that exports an image — a layout export or a map render — where the style is the entire point.

This separation is worth relying on: a pipeline can compute results with unstyled layers and apply cartography only in the final export step, which keeps the expensive rendering work out of the loop.

Conclusion

Symbology automation is mostly a matter of knowing which of four levels a change belongs to — layer, renderer, symbol, symbol layer — and then cloning rather than sharing as you apply it across a project. Build renderers from the data rather than from hard-coded lists so they survive a new class appearing, keep data-defined properties for the cases that genuinely need per-feature variation, and persist the result as QML so the next run starts from the same cartography rather than from someone’s memory of it.