How QGIS Styling Works: Renderers, Symbols and Rules

On this page

Problem statement

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

landuse.setRenderer(categorised_by("fclass"))
print(type(landuse.renderer()).__name__)
try:
    landuse.renderer().symbol()
except AttributeError as exc:
    print("AttributeError:", exc)
symbol = landuse.renderer().symbols(QgsRenderContext())[0]
print(type(symbol).__name__, "->", type(symbol.symbolLayer(0)).__name__)
QgsCategorizedSymbolRenderer
AttributeError: 'QgsCategorizedSymbolRenderer' object has no attribute 'symbol'
QgsFillSymbol -> QgsSimpleFillSymbolLayer

Four levels for "make the land use green". 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. Every example below runs headless under QGIS 4.2 on Rutland's OpenStreetMap land use, roads and places and its Land Registry parcels, and counts the pixels each style actually drew.

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.PyQt.QtGui import QColor

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

# single symbol: a new layer starts with a single-symbol renderer
symbol = QgsSymbol.defaultSymbol(parcels.geometryType())     # right class for the geometry
symbol.setColor(QColor("#0ea5e9"))
symbol.setOpacity(0.8)
parcels.renderer().setSymbol(symbol)
parcels.triggerRepaint()
print(type(parcels.renderer()).__name__, type(symbol).__name__, drawn(parcels), "pixels drawn")

# or load the cartographer's work
message, ok = landuse.loadNamedStyle("styles/landuse.qml")
print(ok, type(landuse.renderer()).__name__, len(landuse.renderer().categories()), "categories")
QgsSingleSymbolRenderer QgsFillSymbol 137176 pixels drawn
True QgsCategorizedSymbolRenderer 19 categories

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

The styling hierarchy from renderer down to symbol layer properties. three levels, one question eachrendererwhich symbol per feature?symbolmarker Β· line Β· fillsymbol layerswhat is actually drawnpropertiescolour, width, sizedata-defined overridesan expression insteadsetColor sets the fill; the outline lives on the symbol layer spatialworkflow.io
Renderer chooses, symbol composes, symbol layers draw.

Single, categorised, graduated, rule-based and data-defined

Renderer types and what each decides. five rendererschooses byuse forsingle symbolnothing β€” one symbola uniform layercategoriseda field valueclasses, land usegraduateda numeric rangechoroplethsrule-basedβœ“expressions, in orderanything complexheatmappoint densityhot spotsRule-based is a superset β€” it can express the other three spatialworkflow.io
Five renderers β€” the choice is entirely about how the symbol is selected.

Single symbol

from qgis.core import QgsSingleSymbolRenderer
from qgis.PyQt.QtCore import Qt

symbol = QgsSymbol.defaultSymbol(parcels.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.PenStyle.SolidLine)

parcels.setRenderer(QgsSingleSymbolRenderer(symbol))
parcels.triggerRepaint()
print("fill", symbol.color().name(), "| outline", fill.strokeColor().name(), fill.strokeWidth(),
      symbol.outputUnit().name)
fill #e8f4fd | outline #1a3a6b 0.4 Millimeters

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. The widths are in millimetres unless you say otherwise.

Categorised: one symbol per value

from qgis.core import QgsCategorizedSymbolRenderer, QgsRendererCategory

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

def categories_for(palette, other=None):
    categories = []
    for value, colour in palette.items():
        symbol = QgsSymbol.defaultSymbol(landuse.geometryType())
        symbol.setColor(QColor(colour))
        categories.append(QgsRendererCategory(value, symbol, value.title()))
    if other:                                    # an empty value matches every unlisted value
        fallback = QgsSymbol.defaultSymbol(landuse.geometryType())
        fallback.setColor(QColor(other))
        categories.append(QgsRendererCategory("", fallback, "Other"))
    return categories

for label, cats in (("four classes", categories_for(PALETTE)),
                    ("four classes + Other", categories_for(PALETTE, other="#cbd5e1"))):
    landuse.setRenderer(QgsCategorizedSymbolRenderer("fclass", cats))
    landuse.triggerRepaint()
    print(f"{label:22} {drawn(landuse):>7,} pixels drawn")
four classes            61,135 pixels drawn
four classes + Other    83,652 pixels drawn

A categorised renderer draws only the values it lists. With four of Rutland's 19 land-use classes listed, the meadows, forest and the rest were simply not drawn; the empty-value "Other" category brought them back. Building categories from the data itself is usually better than hard-coding them:

values = sorted({f["fclass"] for f in landuse.getFeatures() if f["fclass"]})
print(f"{len(values)} distinct values: {values}")
19 distinct values: ['allotments', 'cemetery', 'commercial', 'farmland', 'farmyard', 'forest', 'grass', 'heath', 'industrial', 'meadow', 'military', 'nature_reserve', 'orchard', 'park', 'quarry', 'recreation_ground', 'residential', 'retail', 'scrub']

Graduated: classes from a numeric field

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

for method in (QgsClassificationQuantile(), QgsClassificationEqualInterval(), QgsClassificationJenks()):
    renderer = QgsGraduatedSymbolRenderer("area_m2")
    renderer.setClassificationMethod(method)
    renderer.updateClasses(parcels, 5)
    renderer.updateColorRamp(QgsStyle.defaultStyle().colorRamp("Blues"))
    counts = [count(parcels, f'"area_m2" >= {r.lowerValue()} AND "area_m2" <= {r.upperValue()}')
              for r in renderer.ranges()]
    print(f"{method.name():16} breaks {[round(r.upperValue()) for r in renderer.ranges()][:4]}  parcels per class {counts}")
parcels.setRenderer(renderer)
parcels.triggerRepaint()
Equal Count (Quantile) breaks [157, 326, 573, 2121]  parcels per class [4251, 4250, 4251, 4250, 4251]
Equal Interval   breaks [1429967, 2859934, 4289900, 5719867]  parcels per class [21224, 21, 5, 2, 1]
Natural Breaks (Jenks) breaks [84076, 347670, 870492, 2689569]  parcels per class [20196, 802, 179, 71, 9]

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. On Rutland's parcels the difference is not subtle β€” equal interval put almost every parcel in the first class, because a handful of estates run to hundreds of hectares. Choosing deliberately β€” and recording the choice β€” matters when the map informs a decision.

Parcels per class when Rutland’s 21,253 parcel areas were split into five classes by three methods. same parcels, three mapsmethodclass 12345quantile4,2514,2504,2514,2504,251equal interval21,22421521Jenks20,196802179719equal interval put 99.9% of parcels in one class, because a few estates run to hundreds of hectaresThe classification method decides what the map says β€” record which one you used spatialworkflow.io
The graduated run above: one numeric field, three classifications, three different maps.

Rule-based: the expressive one

from qgis.core import QgsRuleBasedRenderer

root = QgsRuleBasedRenderer.Rule(None)

def add_rule(label, expression, colour, min_scale=None, max_scale=None):
    symbol = QgsSymbol.defaultSymbol(landuse.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", '"fclass" = \'residential\' AND $area > 100000', "#0f766e")
add_rule("Residential", '"fclass" = \'residential\'', "#14b8a6")
add_rule("Everything else", "ELSE", "#cbd5e1")

landuse.setRenderer(QgsRuleBasedRenderer(root))
landuse.triggerRepaint()
for rule in root.children():
    expression = rule.filterExpression()
    matched = count(landuse, expression) if expression != "ELSE" else "the rest"
    print(f"{rule.label():18} {expression[:52]:54} {matched}")
print(drawn(landuse), "pixels drawn")
Large residential  "fclass" = 'residential' AND $area > 100000            37
Residential        "fclass" = 'residential'                               159
Everything else    ELSE                                                   the rest
83651 pixels drawn

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. Note that the rules above are not exclusive β€” a large residential area matches both residential rules and is drawn by both, the second on top β€” unless the renderer is told to stop at the first match. 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 QgsExpression, QgsProperty, QgsSymbolLayer

SIZE_RULES = {
    "CASE value WHEN": "CASE \"fclass\" WHEN 'town' THEN 6 WHEN 'village' THEN 3 ELSE 1 END",
    "CASE WHEN test": "CASE WHEN \"fclass\" = 'town' THEN 6 WHEN \"fclass\" = 'village' THEN 3 ELSE 1 END",
}
towns_only = QgsVectorLayer(places.source(), "towns", "ogr")
towns_only.setSubsetString("\"fclass\" = 'town'")
towns_only.setRenderer(QgsSingleSymbolRenderer(QgsSymbol.defaultSymbol(places.geometryType())))
print(f"{'default 2 mm marker':18} {drawn(towns_only, extent=places.extent()):>5} pixels for the 2 towns")

for label, expression in SIZE_RULES.items():
    marker = QgsSymbol.defaultSymbol(places.geometryType())    # take the symbol you are styling
    marker.symbolLayer(0).setDataDefinedProperty(QgsSymbolLayer.Property.Size, QgsProperty.fromExpression(expression))
    marker.symbolLayer(0).setDataDefinedProperty(
        QgsSymbolLayer.Property.FillColor,
        QgsProperty.fromExpression("CASE WHEN \"fclass\" = 'town' THEN '#1a3a6b' ELSE '#0ea5e9' END"))
    towns_only.setRenderer(QgsSingleSymbolRenderer(marker))
    parsed = QgsExpression(expression)
    print(f"{label:18} {drawn(towns_only, extent=places.extent()):>5} pixels | parser error: "
          f"{parsed.parserErrorString().strip().splitlines()[0] if parsed.hasParserError() else "none"}")
places.setRenderer(QgsSingleSymbolRenderer(marker.clone()))    # a symbol belongs to one renderer
default 2 mm marker   136 pixels for the 2 towns
CASE value WHEN      132 pixels | parser error: syntax error, unexpected QUOTED_COLUMN_REF, expecting WHEN
CASE WHEN test       935 pixels | parser error: none

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. The run above shows its trap. The SQL-style CASE "fclass" WHEN 'town' THEN … is not QGIS expression syntax β€” only the CASE WHEN condition THEN form is β€” and a size expression that does not parse is not an error when the map draws: the towns came out at the default size, and only QgsExpression(...).hasParserError() said why. Check an expression before handing it to a style. And take the symbol you are styling rather than layer.renderer().symbol(): only a single-symbol renderer has that method.

Labels are a separate system

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

settings = QgsPalLayerSettings()
settings.fieldName = '"name" || \' (\' || "fclass" || \')\''
settings.isExpression = True
settings.placement = Qgis.LabelPlacement.OverPoint           # QgsPalLayerSettings.OverPoint is another enum in QGIS 4

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

towns = QgsVectorLayer(places.source(), "towns", "ogr")
towns.setSubsetString("\"fclass\" = 'town'")
towns.setRenderer(QgsSingleSymbolRenderer(QgsSymbol.defaultSymbol(towns.geometryType())))
county = places.extent()
unlabelled = drawn(towns, extent=county)
towns.setLabeling(QgsVectorLayerSimpleLabeling(settings))
print("labeling set, not enabled:", drawn(towns, extent=county) - unlabelled, "extra pixels")
towns.setLabelsEnabled(True)
print("labels enabled:           ", drawn(towns, extent=county) - unlabelled, "extra pixels for",
      [f["name"] for f in towns.getFeatures()])
labeling set, not enabled: 0 extra pixels
labels enabled:            982 extra pixels for ['Oakham', 'Uppingham']

Labelling has its own hierarchy β€” QgsPalLayerSettings for placement and content, QgsTextFormat for typography β€” and is attached to the layer separately from the renderer. Setting the labeling drew nothing until setLabelsEnabled(True).

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

parcels.saveNamedStyle("styles/parcels.qml")                  # save from a styled layer

fresh = QgsVectorLayer("data/rutland.gpkg|layername=parcels", "parcels", "ogr")
message, ok = fresh.loadNamedStyle("styles/parcels.qml")        # load in the pipeline
if not ok:
    raise RuntimeError(f"style failed to load: {message}")
print(ok, type(fresh.renderer()).__name__, len(fresh.renderer().ranges()), "classes |",
      Path("styles/parcels.qml").read_text().splitlines()[0])
True QgsGraduatedSymbolRenderer 5 classes | <!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>

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.

Styling Rutland's land use, roads and parcels

The data used on this page

Rutland's land use, roads and places from Geofabrik's OpenStreetMap extract of 1 January 2026 (Β© OpenStreetMap contributors, ODbL), and HM Land Registry's INSPIRE parcels for the county with each parcel's area (Β© Crown copyright and database rights, HM Land Registry; OGL). The setup plays the cartographer: it builds a land-use style and saves it as styles/landuse.qml. drawn() renders a layer on white and counts the pixels that are not white β€” the page's way of checking that a style drew something.

import os
import shutil
from pathlib import Path

import requests
from osgeo import gdal

gdal.UseExceptions()
for folder in ("data", "out", "styles"):
    Path(folder).mkdir(exist_ok=True)
if not Path("data/rutland.gpkg").exists():
    session = requests.Session()
    Path("data/rutland.shp.zip").write_bytes(session.get(
        "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
    osm = "/vsizip/data/rutland.shp.zip/"
    for layer, shp, fields in (("landuse", "gis_osm_landuse_a_free_1", "fclass"),
                               ("roads", "gis_osm_roads_free_1", "fclass,name"),
                               ("places", "gis_osm_places_free_1", "fclass,name,population")):
        gdal.VectorTranslate("data/rutland.gpkg", osm + shp + ".shp", layerName=layer, accessMode="append",
                             dstSRS="EPSG:27700", reproject=True, SQLStatement=f"SELECT {fields} FROM {shp}")
    Path("data/Rutland.zip").write_bytes(session.get(
        "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300).content)
    gdal.VectorTranslate("data/rutland.gpkg", "/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
                         accessMode="append", layerName="parcels", SQLDialect="SQLite",
                         SQLStatement="SELECT INSPIREID, ST_Area(geometry) AS area_m2, geometry FROM PREDEFINED")

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import (Qgis, QgsApplication, QgsCategorizedSymbolRenderer, QgsFeatureRequest,
                       QgsMapRendererSequentialJob, QgsMapSettings, QgsProject, QgsRendererCategory,
                       QgsRenderContext, QgsSymbol, QgsVectorLayer)
from qgis.PyQt.QtCore import QSize
from qgis.PyQt.QtGui import QColor
qgs = QgsApplication([], False)
qgs.initQgis()


def drawn(layer, size=500, extent=None):
    settings = QgsMapSettings()
    settings.setLayers([layer])
    settings.setOutputSize(QSize(size, size))
    settings.setExtent(extent or layer.extent())
    settings.setDestinationCrs(layer.crs())
    settings.setBackgroundColor(QColor("white"))
    job = QgsMapRendererSequentialJob(settings)
    job.start()
    job.waitForFinished()
    image = job.renderedImage()
    return sum(image.pixel(x, y) & 0xFFFFFF != 0xFFFFFF for x in range(size) for y in range(size))


def count(layer, expression):
    return sum(1 for _ in layer.getFeatures(QgsFeatureRequest().setFilterExpression(expression)))


landuse = QgsVectorLayer("data/rutland.gpkg|layername=landuse", "landuse", "ogr")
roads = QgsVectorLayer("data/rutland.gpkg|layername=roads", "roads", "ogr")
places = QgsVectorLayer("data/rutland.gpkg|layername=places", "places", "ogr")


def categorised_by(field):
    categories = []
    for i, value in enumerate(sorted(landuse.uniqueValues(landuse.fields().indexOf(field)))):
        symbol = QgsSymbol.defaultSymbol(landuse.geometryType())
        symbol.setColor(QColor.fromHsv(int(18 * i), 110, 225))
        categories.append(QgsRendererCategory(value, symbol, value))
    return QgsCategorizedSymbolRenderer(field, categories)


landuse.setRenderer(categorised_by("fclass"))
landuse.saveNamedStyle("styles/landuse.qml")
landuse = QgsVectorLayer("data/rutland.gpkg|layername=landuse", "landuse", "ogr")
print("QGIS", Qgis.QGIS_VERSION.split("-")[0], "|", landuse.featureCount(), "land-use polygons,",
      roads.featureCount(), "roads,", places.featureCount(), "places")
QGIS 4.2.2 | 2405 land-use polygons, 7292 roads, 300 places

Example 1: A reusable styling helper

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, "setStrokeWidth"):          # fills and markers; a line layer has setWidth
        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


for layer, styled in ((roads, style_single(roads, fill="#1a3a6b")),
                      (landuse, style_categories(landuse, "fclass", PALETTE)),
                      (parcels, style_graduated(parcels, "area_m2"))):
    print(f"{layer.name():8} {type(layer.renderer()).__name__:32} {drawn(layer):>7,} pixels drawn")
roads    QgsSingleSymbolRenderer           37,137 pixels drawn
landuse  QgsCategorizedSymbolRenderer      83,652 pixels drawn
parcels  QgsGraduatedSymbolRenderer       137,176 pixels drawn

Example 2: Inspect a style you have been given

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()[:4]:
            print(f"  category {category.value()!r:14} {category.label():14} {category.symbol().color().name()}")
        print(f"  ... {len(renderer.categories())} categories in all")
    if hasattr(renderer, "ranges"):
        for r in renderer.ranges():
            print(f"  {r.lowerValue():>12,.1f} - {r.upperValue():>12,.1f}  {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())
    symbol = renderer.symbols(QgsRenderContext())[0]
    print("  symbol layers:", [type(symbol.symbolLayer(i)).__name__ for i in range(symbol.symbolLayerCount())])


given = QgsVectorLayer("data/rutland.gpkg|layername=landuse", "given", "ogr")
given.loadNamedStyle("styles/landuse.qml")
describe_style(given)
renderer: QgsCategorizedSymbolRenderer
  field: fclass
  category 'allotments'   allotments     #e18080
  category 'cemetery'     cemetery       #e19d80
  category 'commercial'   commercial     #e1ba80
  category 'farmland'     farmland       #e1d780
  ... 19 categories in all
  symbol layers: ['QgsSimpleFillSymbolLayer']

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

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)

main_roads = QgsVectorLayer(roads.source(), "main roads", "ogr")
main_roads.setSubsetString("\"fclass\" IN ('trunk', 'primary', 'secondary')")
main_roads.setRenderer(QgsSingleSymbolRenderer(symbol))
cased = drawn(main_roads)
symbol.deleteSymbolLayer(1)                     # the casing alone
print("casing + centre line:", cased, "non-white pixels | casing alone:", drawn(main_roads))
casing + centre line: 12806 non-white pixels | casing alone: 16688

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. The white centre line painted over the casing, so the cased road had fewer non-white pixels than the casing alone: the middle of each road became white again, which is exactly the effect.

Example 4: Apply a style library across a project

STYLES = {
    "parcels": "styles/parcels.qml",
    "landuse": "styles/landuse.qml",
    "roads": "styles/roads.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


QgsProject.instance().addMapLayers([QgsVectorLayer(f"data/rutland.gpkg|layername={n}", n, "ogr")
                                     for n in ("parcels", "landuse", "roads", "places")])
for name, status in apply_styles().items():
    print(f"{name:10} {status}")
landuse    ok
parcels    ok
places     no style defined
roads      missing style file styles/roads.qml

The report is the point: a missing .qml or a layer with no style defined is a line in the output rather than a map that quietly looks like the default.

Renderer, symbol and symbol layer

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

A cased road symbol built from two stacked symbol layers. a symbol is a stacklayer 0: casingdrawn first, widerlayer 1: centredrawn on top, narrowerthe resulta cased roadIndex 0 is the bottom β€” cartographic effects are stacking order spatialworkflow.io
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.

Where colours live, and where styles are stored

What survived saving a style as QML and as SLD and loading it back, in QGIS 4.2. a style through two formatsQMLβœ“categorised stays categorisedβœ“data-defined size keptβœ“picked up from beside the dataβœ“QGIS's own formatSLDβœ•categorised came back rule-basedβœ•data-defined size lostβœ•keep it for systems that need SLDKeep the .qml as the source; export SLD only for the system that needs it spatialworkflow.io
The .qml-versus-.sld run below, in two columns.
  • symbol.setColor() sets the fill: outline colour and width live on the symbol layer, not the symbol.
  • Always call triggerRepaint() after changing a style on a layer that is on screen; otherwise the canvas keeps the old rendering, which looks like the style did not apply.
  • Styles can reference missing fields: a .qml that classifies on a field your data lacks loads without error and draws nothing β€” the layout export guide shows it.
  • Colour ramp names are style-library dependent: QgsStyle.defaultStyle().colorRampNames() lists what is available β€” 35 in this installation.
  • Rule order matters: rules are evaluated in order and are not exclusive unless you make them so; put the specific ones before the general, and ELSE last.
  • A symbol belongs to the renderer it is given to: handing the same symbol object to two renderers crashed this page with a segmentation fault when the layers were deleted β€” pass symbol.clone() to the second.
  • Symbol units: millimetres by default, but map units and pixels are available β€” a width that looks right at one scale may not at another.

.qml versus .sld

QML is QGIS's own format and captures everything; SLD is the OGC standard and does not:

sld_layer = QgsVectorLayer("data/rutland.gpkg|layername=landuse", "sld", "ogr")
sld_layer.setRenderer(categorised_by("fclass"))
sld_layer.saveSldStyle("out/landuse.sld")
back = QgsVectorLayer("data/rutland.gpkg|layername=landuse", "back", "ogr")
back.loadSldStyle("out/landuse.sld")
print("categorised, via SLD ->", type(back.renderer()).__name__)

marker = QgsSymbol.defaultSymbol(places.geometryType())
marker.symbolLayer(0).setDataDefinedProperty(QgsSymbolLayer.Property.Size,
                                             QgsProperty.fromExpression("CASE \"fclass\" WHEN 'town' THEN 6 ELSE 2 END"))
places.setRenderer(QgsSingleSymbolRenderer(marker))
places.saveSldStyle("out/places.sld")
back = QgsVectorLayer("data/rutland.gpkg|layername=places", "back", "ogr")
back.loadSldStyle("out/places.sld")
print("data-defined size survives SLD:",
      back.renderer().symbol().symbolLayer(0).dataDefinedProperties().isActive(QgsSymbolLayer.Property.Size))
places.saveNamedStyle("out/places.qml")
back = QgsVectorLayer("data/rutland.gpkg|layername=places", "back", "ogr")
back.loadNamedStyle("out/places.qml")
print("data-defined size survives QML:",
      back.renderer().symbol().symbolLayer(0).dataDefinedProperties().isActive(QgsSymbolLayer.Property.Size))
categorised, via SLD -> QgsRuleBasedRenderer
data-defined size survives SLD: False
data-defined size survives QML: True

A categorised style came back from SLD as a rule-based one, and the data-defined size did not come back at all; through QML it did. Use SLD when another system has to read the style, and keep the .qml as the source.

Where a style is stored

In the project by default, as part of the layer. Two other places are loaded automatically when a layer is created:

shutil.copy("data/rutland.gpkg", "out/beside.gpkg")
shutil.copy("styles/landuse.qml", "out/beside.qml")          # same base name as the data file
print("with beside.qml next to it:", type(QgsVectorLayer("out/beside.gpkg|layername=landuse", "a", "ogr").renderer()).__name__)

shutil.copy("data/rutland.gpkg", "out/instyles.gpkg")
styled = QgsVectorLayer("out/instyles.gpkg|layername=landuse", "b", "ogr")
styled.loadNamedStyle("styles/landuse.qml")
styled.saveStyleToDatabaseV2("default", "Rutland land use", True, "")
print("with a default in layer_styles:", type(QgsVectorLayer("out/instyles.gpkg|layername=landuse", "c", "ogr").renderer()).__name__)
print("with neither:", type(QgsVectorLayer("data/rutland.gpkg|layername=landuse", "d", "ogr").renderer()).__name__)
with beside.qml next to it: QgsCategorizedSymbolRenderer
with a default in layer_styles: QgsCategorizedSymbolRenderer
with neither: QgsSingleSymbolRenderer

A .qml with the data file's base name, and a default style saved into a GeoPackage's layer_styles table, were both picked up by a plain QgsVectorLayer(...) in a script β€” which is convenient, and also the explanation when a layer does not look the way the code styling it expects.

The page's QGIS session ends here, releasing its layers before exitQgis() β€” the order the segmentation-fault guide recommends:

from qgis.core import QgsMapLayer
import gc

held = [name for name, value in globals().items() if isinstance(value, QgsMapLayer)]
for name in held:
    del globals()[name]
gc.collect()
QgsProject.instance().clear()
qgs.exitQgis()
print(f"released {len(held)} layers; QGIS closed")
released 13 layers; QGIS closed

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 on the symbol layer: setDataDefinedProperty(QgsSymbolLayer.Property.Size, QgsProperty.fromExpression('scale_linear("pop", 0, 10000, 2, 12)')) β€” QgsSymbolLayer.PropertySize in QGIS 3 code, which still works in 4.2.

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 also store styles in a layer_styles table, and a .qml with the data file's base name sits beside it; both were loaded automatically when a script created the layer with QgsVectorLayer(...).