How QGIS Styling Works: Renderers, Symbols and Rules

Problem statement

Styling in the QGIS GUI is a few clicks. In Python it is a stack of classes whose relationship is not obvious:

layer.renderer()                                   # QgsCategorizedSymbolRenderer
layer.renderer().symbol()                          # AttributeError β€” no such method here
layer.renderer().symbols(QgsRenderContext())[0]    # QgsFillSymbol
layer.renderer().symbols(QgsRenderContext())[0].symbolLayer(0)   # QgsSimpleFillSymbolLayer

Four levels for "make the parcels blue". And when a style has to be reproduced across a hundred generated maps, or applied by a pipeline, guessing at that hierarchy is not a workable approach.

The model is actually simple once seen whole: a renderer decides which symbol each feature gets, a symbol is a stack of symbol layers, and each symbol layer draws one thing. Everything else β€” categorised, graduated, rule-based, data-defined β€” is a variation on those three levels.

Quick answer

Three levels, plus one escape hatch:

  1. Renderer β€” chooses a symbol per feature: single, categorised, graduated, rule-based, heatmap
  2. Symbol β€” one per geometry type (QgsMarkerSymbol, QgsLineSymbol, QgsFillSymbol), containing…
  3. Symbol layers β€” the actual drawing: simple fill, outline, marker, SVG, gradient β€” stacked and drawn in order
  4. Data-defined properties β€” any symbol setting can be driven by an expression instead of a constant
  5. In practice: design it in the GUI, save a .qml, and load it from code
from qgis.core import QgsVectorLayer, QgsSymbol, QgsCategorizedSymbolRenderer, QgsRendererCategory
from qgis.PyQt.QtGui import QColor

layer = QgsVectorLayer("data/parcels.gpkg|layername=parcels", "parcels", "ogr")

# single symbol
symbol = QgsSymbol.defaultSymbol(layer.geometryType())     # right class for the geometry
symbol.setColor(QColor("#0ea5e9"))
symbol.setOpacity(0.8)
layer.renderer().setSymbol(symbol)
layer.triggerRepaint()

# or load the cartographer's work
message, ok = layer.loadNamedStyle("styles/parcels.qml")
print(ok, message)

QgsSymbol.defaultSymbol(layer.geometryType()) is the line to remember: it returns a marker, line or fill symbol to match the layer, so you never have to pick the class yourself.

The three levels

Layered stack from renderer through symbol to symbol layers and their properties.
Renderer chooses, symbol composes, symbol layers draw.

Step-by-step solution

Grid of renderer types with what each decides and when to use it.
Five renderers β€” the choice is entirely about how the symbol is selected.

Single symbol

from qgis.core import QgsSymbol, QgsSingleSymbolRenderer, QgsSimpleFillSymbolLayer
from qgis.PyQt.QtGui import QColor
from qgis.PyQt.QtCore import Qt

symbol = QgsSymbol.defaultSymbol(layer.geometryType())
symbol.setColor(QColor("#e8f4fd"))

# reach into the symbol layer for the properties a symbol does not expose
fill = symbol.symbolLayer(0)
fill.setStrokeColor(QColor("#1a3a6b"))
fill.setStrokeWidth(0.4)
fill.setStrokeStyle(Qt.SolidLine)

layer.setRenderer(QgsSingleSymbolRenderer(symbol))
layer.triggerRepaint()

symbol.setColor() sets the fill; the outline lives on the symbol layer. That split is the first thing that surprises people, and it follows directly from the hierarchy: a symbol delegates drawing to its layers.

Categorised: one symbol per value

from qgis.core import QgsSymbol, QgsRendererCategory, QgsCategorizedSymbolRenderer
from qgis.PyQt.QtGui import QColor

PALETTE = {
    "residential": "#14b8a6",
    "commercial":  "#0ea5e9",
    "industrial":  "#1a3a6b",
    "agricultural":"#a3e635",
}

categories = []
for value, colour in PALETTE.items():
    symbol = QgsSymbol.defaultSymbol(layer.geometryType())
    symbol.setColor(QColor(colour))
    categories.append(QgsRendererCategory(value, symbol, value.title()))

# an "everything else" category: an empty value matches unlisted values
other = QgsSymbol.defaultSymbol(layer.geometryType())
other.setColor(QColor("#cbd5e1"))
categories.append(QgsRendererCategory("", other, "Other"))

layer.setRenderer(QgsCategorizedSymbolRenderer("class", categories))
layer.triggerRepaint()

Building categories from the data itself is usually better than hard-coding them:

values = sorted({f["class"] for f in layer.getFeatures() if f["class"]})
print(f"{len(values)} distinct values: {values}")

Graduated: classes from a numeric field

from qgis.core import (QgsGraduatedSymbolRenderer, QgsClassificationQuantile,
                       QgsClassificationEqualInterval, QgsClassificationJenks,
                       QgsStyle)

renderer = QgsGraduatedSymbolRenderer("area_m2")
renderer.setClassificationMethod(QgsClassificationQuantile())   # or Jenks, EqualInterval
renderer.updateClasses(layer, 5)

ramp = QgsStyle.defaultStyle().colorRamp("Blues")
renderer.updateColorRamp(ramp)

layer.setRenderer(renderer)
layer.triggerRepaint()

for r in layer.renderer().ranges():
    print(f"{r.lowerValue():12,.0f} – {r.upperValue():12,.0f}  {r.label()}")

The classification method is a policy decision, not an aesthetic one: quantiles give equal counts per class, equal interval gives equal ranges, Jenks minimises within-class variance. Choosing deliberately β€” and recording the choice β€” matters when the map informs a decision.

Rule-based: the expressive one

from qgis.core import QgsRuleBasedRenderer, QgsSymbol
from qgis.PyQt.QtGui import QColor

root = QgsRuleBasedRenderer.Rule(None)

def add_rule(label, expression, colour, min_scale=None, max_scale=None):
    symbol = QgsSymbol.defaultSymbol(layer.geometryType())
    symbol.setColor(QColor(colour))
    rule = QgsRuleBasedRenderer.Rule(symbol, label=label, filterExp=expression)
    if min_scale:                                   # visible only within a scale band
        rule.setMinimumScale(min_scale)
    if max_scale:
        rule.setMaximumScale(max_scale)
    root.appendChild(rule)
    return rule

add_rule("Large residential", '"class" = \'residential\' AND "area_m2" > 1000', "#0f766e")
add_rule("Residential", '"class" = \'residential\'', "#14b8a6")
add_rule("Everything else", "ELSE", "#cbd5e1")

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

Rule-based rendering is a superset of the others: any categorised or graduated style can be expressed as rules, plus scale-dependent visibility, nested rules and arbitrary expressions. It is what to reach for when the styling logic stops being "one field, one symbol".

Data-defined properties: expressions instead of constants

from qgis.core import QgsProperty, QgsSymbolLayer

symbol = layer.renderer().symbol() if hasattr(layer.renderer(), "symbol") else None
symbol_layer = symbol.symbolLayer(0)

# size driven by a field
symbol_layer.setDataDefinedProperty(
    QgsSymbolLayer.PropertySize,
    QgsProperty.fromExpression('scale_linear("population", 0, 10000, 2, 12)'))

# colour driven by an expression
symbol_layer.setDataDefinedProperty(
    QgsSymbolLayer.PropertyFillColor,
    QgsProperty.fromExpression(
        "CASE WHEN \"area_m2\" > 5000 THEN '#1a3a6b' ELSE '#0ea5e9' END"))

layer.triggerRepaint()

Almost every symbol property β€” size, colour, rotation, offset, opacity, stroke width β€” can be data-defined. It is the mechanism behind proportional symbols, rotated arrows along a bearing field, and any styling that varies continuously rather than by class.

Labels are a separate system

from qgis.core import QgsPalLayerSettings, QgsTextFormat, QgsVectorLayerSimpleLabeling
from qgis.PyQt.QtGui import QColor, QFont

settings = QgsPalLayerSettings()
settings.fieldName = '"name" || \' (\' || format_number("area_m2" / 10000, 1) || \' ha)\''
settings.isExpression = True
settings.placement = QgsPalLayerSettings.OverPoint

text = QgsTextFormat()
text.setFont(QFont("Noto Sans", 9))
text.setColor(QColor("#1e293b"))
settings.setFormat(text)

layer.setLabeling(QgsVectorLayerSimpleLabeling(settings))
layer.setLabelsEnabled(True)
layer.triggerRepaint()

Labelling has its own hierarchy β€” QgsPalLayerSettings for placement and content, QgsTextFormat for typography β€” and is attached to the layer separately from the renderer.

The practical workflow: design in the GUI, load in code

from pathlib import Path

# save from a styled layer (do this once, in the GUI or a notebook)
layer.saveNamedStyle("styles/parcels.qml")

# load in the pipeline
message, ok = layer.loadNamedStyle("styles/parcels.qml")
if not ok:
    raise RuntimeError(f"style failed to load: {message}")
layer.triggerRepaint()

This is the workflow worth adopting for anything cartographic. A .qml is XML, version-controllable, produced by the person with the design skill, and applied by a pipeline in two lines. Building renderers in Python is right when the classification depends on the data β€” breaks computed from this month's values β€” and unnecessary otherwise.

Code examples

Example 1: a reusable styling helper

from qgis.core import (QgsSymbol, QgsSingleSymbolRenderer, QgsRendererCategory,
                       QgsCategorizedSymbolRenderer, QgsGraduatedSymbolRenderer,
                       QgsClassificationQuantile, QgsStyle)
from qgis.PyQt.QtGui import QColor

def style_single(layer, fill="#0ea5e9", stroke="#1a3a6b", stroke_width=0.3, opacity=1.0):
    symbol = QgsSymbol.defaultSymbol(layer.geometryType())
    symbol.setColor(QColor(fill))
    symbol.setOpacity(opacity)
    sl = symbol.symbolLayer(0)
    if hasattr(sl, "setStrokeColor"):
        sl.setStrokeColor(QColor(stroke))
        sl.setStrokeWidth(stroke_width)
    layer.setRenderer(QgsSingleSymbolRenderer(symbol))
    layer.triggerRepaint()
    return layer

def style_categories(layer, field, palette: dict, other="#cbd5e1"):
    categories = []
    for value, colour in palette.items():
        symbol = QgsSymbol.defaultSymbol(layer.geometryType())
        symbol.setColor(QColor(colour))
        categories.append(QgsRendererCategory(value, symbol, str(value).title()))
    if other:
        fallback = QgsSymbol.defaultSymbol(layer.geometryType())
        fallback.setColor(QColor(other))
        categories.append(QgsRendererCategory("", fallback, "Other"))
    layer.setRenderer(QgsCategorizedSymbolRenderer(field, categories))
    layer.triggerRepaint()
    return layer

def style_graduated(layer, field, classes=5, ramp="Blues", method=None):
    renderer = QgsGraduatedSymbolRenderer(field)
    renderer.setClassificationMethod(method or QgsClassificationQuantile())
    renderer.updateClasses(layer, classes)
    renderer.updateColorRamp(QgsStyle.defaultStyle().colorRamp(ramp))
    layer.setRenderer(renderer)
    layer.triggerRepaint()
    return layer

Example 2: inspect a style you have been given

from qgis.core import QgsRenderContext

def describe_style(layer) -> None:
    renderer = layer.renderer()
    print(f"renderer: {type(renderer).__name__}")

    if hasattr(renderer, "classAttribute"):
        print(f"  field: {renderer.classAttribute()}")
    if hasattr(renderer, "categories"):
        for category in renderer.categories():
            print(f"  category {category.value()!r:22} {category.label():20} "
                  f"{category.symbol().color().name()}")
    if hasattr(renderer, "ranges"):
        for r in renderer.ranges():
            print(f"  {r.lowerValue():>12,.1f} – {r.upperValue():>12,.1f}  "
                  f"{r.symbol().color().name()}")
    if hasattr(renderer, "rootRule"):
        def walk(rule, depth=0):
            print("  " * (depth + 1) + f"{rule.label() or '(root)'}: {rule.filterExpression()}")
            for child in rule.children():
                walk(child, depth + 1)
        walk(renderer.rootRule())

    for symbol in renderer.symbols(QgsRenderContext()):
        for i in range(symbol.symbolLayerCount()):
            sl = symbol.symbolLayer(i)
            print(f"  symbol layer {i}: {type(sl).__name__}")

describe_style(layer)

Reading an inherited style programmatically is often the quickest way to understand what a colleague's map is actually doing.

Example 3: stacked symbol layers for a cased road

from qgis.core import QgsLineSymbol, QgsSimpleLineSymbolLayer, QgsSingleSymbolRenderer
from qgis.PyQt.QtGui import QColor

symbol = QgsLineSymbol()
symbol.deleteSymbolLayer(0)                     # start from nothing

casing = QgsSimpleLineSymbolLayer.create({})    # drawn first, so underneath
casing.setColor(QColor("#1a3a6b"))
casing.setWidth(1.4)
symbol.appendSymbolLayer(casing)

centre = QgsSimpleLineSymbolLayer.create({})    # drawn on top
centre.setColor(QColor("#ffffff"))
centre.setWidth(0.8)
symbol.appendSymbolLayer(centre)

roads.setRenderer(QgsSingleSymbolRenderer(symbol))
roads.triggerRepaint()

Stacking symbol layers is how the cartographic effects are built β€” road casings, halos, hatch fills over solid fills. Order is draw order: index 0 first, underneath.

Example 4: apply a style library across a project

from pathlib import Path
from qgis.core import QgsProject

STYLES = {
    "parcels": "styles/parcels.qml",
    "roads": "styles/roads.qml",
    "buildings": "styles/buildings.qml",
}

def apply_styles(project=None, styles=STYLES) -> dict:
    project = project or QgsProject.instance()
    report = {}
    for layer in project.mapLayers().values():
        qml = styles.get(layer.name().lower())
        if not qml:
            report[layer.name()] = "no style defined"
            continue
        if not Path(qml).exists():
            report[layer.name()] = f"missing style file {qml}"
            continue
        message, ok = layer.loadNamedStyle(qml)
        report[layer.name()] = "ok" if ok else f"failed: {message}"
        layer.triggerRepaint()
    return report

for name, status in apply_styles().items():
    print(f"{name:16} {status}")

Explanation

QGIS separates styling into three responsibilities, and every apparent complication follows from that separation.

A cased road symbol built from two stacked symbol layers, drawn in order.
A symbol is a stack; drawing order is layer order, and that is where cartographic effects come from.

The renderer answers one question: given this feature, which symbol should draw it? A single-symbol renderer always answers the same way; a categorised one looks up a field value; a graduated one finds the class a number falls into; a rule-based one evaluates expressions in order. That is the only thing a renderer does, which is why swapping renderers changes the classification and not the appearance of an individual symbol.

The symbol answers a different question: what does drawing look like? It is a container of symbol layers, and it exists in three flavours matching the geometry types β€” marker, line, fill. QgsSymbol.defaultSymbol(layer.geometryType()) picks the right one, which saves both a decision and a class of bugs.

The symbol layers do the drawing, and there can be several. A fill symbol might be a simple fill plus an outline plus a hatch; a line symbol might be a thick dark line under a thin light one, which is how a cased road is drawn. Because they are drawn in order, index 0 is the bottom, and rearranging them is a cartographic tool rather than an implementation detail.

Data-defined properties cut across all three levels. Any property β€” size, colour, rotation, offset β€” can be a QgsProperty holding an expression instead of a constant, evaluated per feature at draw time. That is what makes proportional symbols, bearing-rotated arrows and continuous colour possible without a class for every value, and it is the same expression engine used by the Field Calculator and labelling.

Which suggests the workflow most teams end up with. Cartography is a design activity and belongs in the GUI, where you can see what you are doing; the result is saved as a .qml and version-controlled like any other asset. Code then loads that style, and builds renderers programmatically only where the classification genuinely depends on the data β€” this month's breaks, this run's categories. That division keeps the map's appearance reviewable and the pipeline short.

Edge cases or notes

  • symbol.setColor() sets the fill: Outline colour and width live on the symbol layer, not the symbol.
  • Always call triggerRepaint(): Otherwise the canvas keeps the old rendering, which looks like the style did not apply.
  • .qml versus .sld: QML is QGIS's own and captures everything; SLD is the OGC standard and loses QGIS-specific effects.
  • Styles can reference missing fields: A .qml whose expressions name a field your data lacks loads without error and renders nothing.
  • Colour ramp names are style-library dependent: QgsStyle.defaultStyle().colorRampNames() lists what is available.
  • Rule order matters: Rules are evaluated in order; put the specific ones before the general, and ELSE last.
  • Symbol units: Millimetres by default, but map units and pixels are available β€” a width that looks right at one scale may not at another.

FAQ

What is the difference between a renderer and a symbol?

The renderer decides which symbol each feature gets; the symbol decides how it is drawn. Changing the renderer changes the classification, not the appearance of any individual symbol.

Why does setColor not change the outline?

Because the outline belongs to the symbol layer, not the symbol. Reach in with symbol.symbolLayer(0).setStrokeColor(...) and setStrokeWidth(...).

Should I build styles in Python or load a .qml?

Load a .qml for anything cartographic β€” it is designed in the GUI, version-controlled, and applied in two lines. Build in Python when the classification depends on the data itself.

How do I make symbol size depend on a field?

Set a data-defined property: setDataDefinedProperty(QgsSymbolLayer.PropertySize, QgsProperty.fromExpression('scale_linear("pop", 0, 10000, 2, 12)')).

Which classification method should I use for a choropleth?

Quantiles for equal counts per class, equal interval for equal ranges, Jenks to minimise within-class variance. It is an analytical choice β€” record which you used.

Why do my labels not appear?

Labelling is separate from the renderer: you need QgsPalLayerSettings, a QgsVectorLayerSimpleLabeling, and layer.setLabelsEnabled(True).

Are styles saved in the project or with the data?

In the project by default. A GeoPackage can store styles in a layer_styles table, and a .qml beside a file is loaded automatically by QGIS when the layer is added.