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:
- Renderer β chooses a symbol per feature: single, categorised, graduated, rule-based, heatmap
- Symbol β one per geometry type (
QgsMarkerSymbol,QgsLineSymbol,QgsFillSymbol), containingβ¦ - Symbol layers β the actual drawing: simple fill, outline, marker, SVG, gradient β stacked and drawn in order
- Data-defined properties β any symbol setting can be driven by an expression instead of a constant
- 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
Step-by-step solution
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.
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. .qmlversus.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
.qmlwhose 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
ELSElast. - Symbol units: Millimetres by default, but map units and pixels are available β a width that looks right at one scale may not at another.
Internal links
- How to Style Layers and Export Map Layouts from PyQGIS
- How to Build and Save a QGIS Project File from Python
- How to Run Field Calculator Expressions from PyQGIS
- The PyQGIS API Map: Which Class Does What
- How to Create a Choropleth Map in Python with GeoPandas
- How to Automate QGIS with Python (PyQGIS): The Complete Workflow
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.