Create a Rule-Based Renderer in PyQGIS

Categorised and graduated renderers each answer one question: what value is in this field, and which band does this number fall into. Real cartography asks compound questions. Draw motorways at every scale but minor roads only below 1:50 000; colour by surface type, but only for roads that are actually open; and render everything left over in grey so nothing silently disappears. That is a rule-based renderer, and in Python it is a small tree of QgsRuleBasedRenderer.Rule objects.

This recipe belongs to Graduated and Categorised Renderers in PyQGIS. It covers building rules, nesting them, scale-dependent rendering, the else rule, and converting an existing renderer into rules you can extend.

The shape of a rule treeA root rule holds three children. The first matches motorways and has no scale limit. The second matches minor roads and only draws below one to fifty thousand. The third is an else rule with no filter, which catches every feature the earlier rules did not match and draws it in grey. Nested under the first rule are two further rules splitting motorways by surface.Every feature is tested against every top-level ruleroot ruleno filter, no symbol"class" = 'motorway'all scales · 2.0 mm blue"class" = 'minor'below 1:50 000 onlyELSEeverything unmatched, greysurface ='asphalt'surface ='concrete'A child rule is tested only if its parent matched — nesting is an AND

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A vector layer with attributes worth branching on.
  • Familiarity with QGIS expression syntax — see Evaluate a QGIS Expression in PyQGIS.

Build a rule tree

from qgis.core import (
    QgsRuleBasedRenderer, QgsSymbol, QgsProject, QgsWkbTypes,
)
from qgis.PyQt.QtGui import QColor

layer = QgsProject.instance().mapLayersByName("roads")[0]

root = QgsRuleBasedRenderer.Rule(None)

def make_rule(label, expression, colour, width):
    symbol = QgsSymbol.defaultSymbol(layer.geometryType())
    symbol.setColor(QColor(colour))
    symbol.setWidth(width)
    rule = QgsRuleBasedRenderer.Rule(symbol)
    rule.setLabel(label)
    rule.setFilterExpression(expression)
    return rule

root.appendChild(make_rule("Motorway", "\"class\" = 'motorway'", "#2563eb", 1.2))
root.appendChild(make_rule("A road", "\"class\" = 'primary'", "#0f766e", 0.8))

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

Breakdown: The root rule is constructed with None as its symbol — it draws nothing itself and exists only to hold children. Each child owns its own symbol, and QgsSymbol.defaultSymbol(layer.geometryType()) produces the right kind (marker, line or fill) without a type check. setLabel() is what appears in the legend, so leaving it empty produces a legend entry with no text. Field names inside the expression are double-quoted and literals single-quoted, which is why the Python string needs escaping — a raw string or single-quoted Python string avoids the backslashes.

Rules are evaluated independently: a feature matching two top-level rules is drawn twice, once per rule. That is a feature, not a bug — it is how a casing-and-fill road style is built — but it surprises people expecting first-match-wins behaviour.

Nest rules to combine conditions

motorway = make_rule("Motorway", "\"class\" = 'motorway'", "#2563eb", 1.2)

lit = make_rule("Lit", "\"lit\" = 'yes'", "#f59e0b", 1.4)
unlit = make_rule("Unlit", "\"lit\" <> 'yes'", "#1e40af", 1.2)

motorway.appendChild(lit)
motorway.appendChild(unlit)
root.appendChild(motorway)

Breakdown: A child rule is only evaluated for features that matched its parent, so nesting expresses class = 'motorway' AND lit = 'yes' without repeating the first condition. The parent still draws its own symbol unless it was created with None; to use the parent purely as a grouping condition, construct it symbol-less and let the children do the drawing. Nesting also keeps the legend tidy, because QGIS renders the tree structure in the layer panel.

Make rules scale-dependent

minor = make_rule("Minor road", "\"class\" = 'minor'", "#59645f", 0.4)
minor.setScaleMinDenom(0)          # no lower limit — visible when zoomed in
minor.setScaleMaxDenom(50000)      # hidden above 1:50 000
root.appendChild(minor)

Breakdown: The denominators are the scale numbers: setScaleMaxDenom(50000) means the rule stops drawing when the map is zoomed out past 1:50 000, because larger denominators are smaller scales. Setting the minimum to 0 means no lower bound. This is the mechanism behind every legible multi-scale map — it lets a dense network thin out as the reader zooms out, rather than collapsing into a solid block of ink — and it is per rule, so different classes can disappear at different scales.

Scale ranges thin the network as the reader zooms outThree panels show the same area at increasing scale denominators. Zoomed in, motorways, A roads and minor roads all draw. At one to fifty thousand the minor roads drop out and the map stays readable. Zoomed further out only motorways remain, so the shape of the network is still visible rather than a solid mass of lines.Each rule chooses the scales at which it is worth drawing1:10 000every class drawn1:50 000minor roads have dropped out1:250 000the network shape survives

Always add an else rule

A feature matching no rule is not drawn. On a layer whose attribute values you do not fully control, that means data disappearing silently.

fallback = QgsSymbol.defaultSymbol(layer.geometryType())
fallback.setColor(QColor("#59645f"))

else_rule = QgsRuleBasedRenderer.Rule(fallback)
else_rule.setLabel("Other")
else_rule.setIsElse(True)
root.appendChild(else_rule)

Breakdown: setIsElse(True) marks the rule as the catch-all: it matches every feature that no sibling rule matched, and it must be the last child for that to be meaningful. Adding one converts an invisible data problem into a visible grey line — a new road class appearing in next quarter's extract shows up immediately rather than being quietly omitted from every map. This is the same defensive instinct as the plausibility checks in Handle Errors and Logging in Unattended Scripts.

Convert an existing renderer to rules

Rather than rebuilding a categorised renderer by hand, convert it and then extend it.

from qgis.core import QgsRuleBasedRenderer

existing = layer.renderer()                       # categorised or graduated
converted = QgsRuleBasedRenderer.convertFromRenderer(existing)
layer.setRenderer(converted)

root = layer.renderer().rootRule()
for rule in root.children():
    rule.setScaleMaxDenom(100000)

Breakdown: convertFromRenderer() accepts a categorised, graduated or single-symbol renderer and returns an equivalent rule tree — one rule per category or class, filters already written. That makes it the fastest way to start: build the classification with the natural breaks helper, convert, then add scale ranges or an else rule. Iterating root.children() gives the generated rules, which can be modified in place before the layer repaints.

Drive symbol properties from expressions

A rule chooses which symbol draws a feature. A data-defined property lets that one symbol vary per feature, which often replaces a dozen rules with one.

from qgis.core import QgsProperty, QgsSymbolLayer

symbol = QgsSymbol.defaultSymbol(layer.geometryType())
symbol_layer = symbol.symbolLayer(0)

symbol_layer.setDataDefinedProperty(
    QgsSymbolLayer.PropertyStrokeWidth,
    QgsProperty.fromExpression('scale_linear("traffic", 0, 20000, 0.3, 3.0)'),
)
symbol_layer.setDataDefinedProperty(
    QgsSymbolLayer.PropertyStrokeColor,
    QgsProperty.fromExpression(
        """CASE WHEN "condition" = 'poor' THEN '#b91c1c' ELSE '#0f766e' END"""
    ),
)

rule = QgsRuleBasedRenderer.Rule(symbol)
rule.setLabel("Roads by traffic and condition")

Breakdown: scale_linear() maps a data range onto a width range, clamping at both ends, so a road carrying twenty thousand vehicles draws at 3 mm and an empty lane at 0.3 mm with everything in between interpolated — a continuous encoding no set of discrete rules can match. The colour expression returns a hex string, which is how a CASE becomes symbology. Both are evaluated per feature at render time, so a change in the underlying data is reflected without touching the renderer.

The trade-off is legibility in the legend: a data-defined width has no natural legend entry, because there are no classes to list. The usual answer is a hybrid — rules for the categories a reader must be able to look up, data-defined properties for the continuous variation within them. The expression syntax is the same one covered in Data-Defined Symbol Size Expression in PyQGIS.

Stepped classes against a continuous encodingFive rules produce five stepped line widths, so every road within a class draws identically and the boundaries between classes are visible as jumps. A data-defined stroke width driven by scale linear produces a smooth ramp across the same range, encoding the actual value of every feature.Five classes, or every valuefive rules, five widthsone rule, scale_linear()Classes give a legend a reader can look up; a ramp gives every feature its own value

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.22 LTR3.9Full rule API including convertFromRenderer().
3.28 LTR3.9Behaviour matches this page.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Unchanged; the rule editor gains reference-scale handling in the GUI only.

Troubleshooting

  • Nothing renders. The root rule was given a symbol but no children, or every child's filter matches nothing. Test the expression with QgsExpression first.
  • Features draw twice. Two top-level rules match the same feature. That is by design; nest them or tighten the filters if it was not intended.
  • The legend entries are blank. setLabel() was not called.
  • The else rule catches everything. It was added before its siblings, or the earlier filters are wrong. It must be the last child.
  • A rule never appears however far you zoom. The scale range is inverted — remember that a larger denominator is a smaller scale.
  • The renderer resets after reloading the project. The layer was styled but the project not saved, or the style was saved to a QML that was not loaded. See Save and Load a QML Style in PyQGIS.

Conclusion

A rule-based renderer is a tree: a symbol-less root, children carrying a filter expression, a symbol and a label, nesting where conditions combine, scale ranges where legibility demands it, and an else rule so nothing vanishes unnoticed. When a categorised renderer already exists, convertFromRenderer() turns it into that tree in one call and leaves you free to extend it.

Frequently Asked Questions

When should I prefer a rule-based renderer? Whenever the symbology depends on more than one field, on scale, or needs a catch-all. For a single field with distinct values, a categorised renderer is simpler and just as fast.

Are rule-based renderers slower? Each rule's expression is evaluated per feature, so a deep tree of complex expressions costs more than a categorised lookup. Keep filters simple, and prefer nesting over repeating a condition in every rule.

Can a rule have several symbol layers? Yes — the rule's symbol is an ordinary QgsSymbol, so casing, dashes and markers along a line all work as usual.

How do I hide a class entirely? Give the rule a filter that matches it and call rule.setActive(False), or simply omit the rule and let the else rule catch it if you still want it drawn faintly.

Does the rule tree appear in the layout legend? Yes, with its nesting. Labels and grouping are worth tidying before exporting — see Add a Legend to a Layout in PyQGIS.