Add Rule-Based Labels in PyQGIS
Simple labeling applies one style to every feature. Real maps need more nuance: label motorways at country scale, add minor streets only when the user zooms in, and skip footpaths entirely. QgsRuleBasedLabeling gives you exactly this by organizing labeling into a tree of rules, each carrying its own filter expression, scale range, and QgsPalLayerSettings. This task page, part of the Labeling & Annotations in PyQGIS cluster, walks through building a road-network labeling tree from a root rule down to scale-aware children.
The pattern mirrors rule-based rendering — if you have set symbol styles by class, the labeling API will feel familiar. The difference is that each rule wraps a label settings object instead of a symbol.
Why reach for rules at all when data-defined Show expressions can hide labels conditionally? Because rules give each class its own full settings object. A motorway rule can use an 11-point bold serif at every scale while a residential rule uses 8-point sans only when zoomed in — different fonts, sizes, colors, placements, and scale ranges, all coexisting on one layer. A single set of data-defined properties cannot vary placement or font family per feature with anything like that clarity. Rules also read like a legend, which makes them maintainable months later.
Prerequisites
- QGIS 3.34 LTR (Python 3.12). The API shown is stable back to 3.28 LTR.
- A line layer of roads with a classification field (commonly
highway,fclass, orroad_class) — OpenStreetMap road extracts work perfectly. - A configured
QgsTextFormatto reuse across rules (see the parent cluster for building one). - Code running in the QGIS Python Console or a standalone script with an initialized application.
How Rule-Based Labeling Is Structured
A QgsRuleBasedLabeling is built from a single root rule. The root usually carries no settings of its own; it is a container. You append child rules to it, and each child holds a QgsPalLayerSettings, an optional filter expression, and an optional scale range. At render time the engine walks the tree and applies every child whose filter matches the feature and whose scale range includes the current map scale.
Step 1: Build a Reusable Label Settings Factory
Each rule needs its own QgsPalLayerSettings. Rather than duplicate configuration, write a small factory that stamps out settings from a shared text format, varying only what changes per rule.
from qgis.PyQt.QtGui import QColor, QFont
from qgis.core import (
QgsTextFormat,
QgsTextBufferSettings,
QgsPalLayerSettings,
QgsUnitTypes,
)
def make_text_format(point_size, color):
fmt = QgsTextFormat()
fmt.setFont(QFont("Noto Sans", point_size))
fmt.setSize(point_size)
fmt.setSizeUnit(QgsUnitTypes.RenderPoints)
fmt.setColor(QColor(color))
buffer = QgsTextBufferSettings()
buffer.setEnabled(True)
buffer.setSize(1.0)
buffer.setSizeUnit(QgsUnitTypes.RenderMillimeters)
buffer.setColor(QColor("#ffffff"))
fmt.setBuffer(buffer)
return fmt
def road_label_settings(field, point_size, color):
settings = QgsPalLayerSettings()
settings.fieldName = field
settings.isExpression = False
settings.placement = QgsPalLayerSettings.Curved
settings.setFormat(make_text_format(point_size, color))
return settings
Breakdown: make_text_format centralizes typography so every rule shares the same white halo and font family while letting size and color vary. road_label_settings returns a fresh QgsPalLayerSettings per call — important, because rules must not share a single mutable settings object. Curved placement bends road names along their geometry, which is what readers expect from a street map.
Step 2: Create the Root Rule and Add Children
QgsRuleBasedLabeling.Rule is the node type. Construct the root with None (it holds no settings), then construct child rules wrapping your settings objects, give each a filter expression and scale range, and append them with appendChild.
from qgis.core import QgsRuleBasedLabeling
# Root rule is a container; pass None for its settings.
root = QgsRuleBasedLabeling.Rule(None)
# --- Child 1: motorways, labeled at every scale ---
motorway = QgsRuleBasedLabeling.Rule(
road_label_settings("name", 11, "#7c2d12")
)
motorway.setFilterExpression("\"highway\" = 'motorway'")
motorway.setDescription("Motorways")
root.appendChild(motorway)
# --- Child 2: secondary roads, only from 1:250000 inward ---
secondary = QgsRuleBasedLabeling.Rule(
road_label_settings("name", 9, "#1f2937")
)
secondary.setFilterExpression("\"highway\" = 'secondary'")
secondary.setMaximumScale(250000) # not shown when zoomed out beyond this
secondary.setMinimumScale(1)
secondary.setDescription("Secondary roads")
root.appendChild(secondary)
# --- Child 3: residential streets, only below 1:25000 ---
residential = QgsRuleBasedLabeling.Rule(
road_label_settings("name", 8, "#374151")
)
residential.setFilterExpression("\"highway\" = 'residential'")
residential.setMaximumScale(25000)
residential.setMinimumScale(1)
residential.setDescription("Residential streets")
root.appendChild(residential)
Breakdown: Each child wraps an independent settings object from the factory. setFilterExpression restricts the rule to one road class. The scale methods are the part that trips people up: in QGIS, setMaximumScale is the largest denominator at which the rule is active — counterintuitively, setMaximumScale(250000) means "do not show when zoomed out past 1:250000." A minimumScale of 1 keeps the rule active at all closer zooms. Leaving both unset (as on the motorway rule) labels at every scale.
Step 3: Attach the Labeling and Refresh
Wrap the root in QgsRuleBasedLabeling, install it, enable labels, and repaint — the same three-call sequence every labeling type requires.
from qgis.core import QgsRuleBasedLabeling, QgsProject
layer = QgsProject.instance().mapLayersByName("roads")[0]
labeling = QgsRuleBasedLabeling(root)
layer.setLabeling(labeling)
layer.setLabelsEnabled(True)
layer.triggerRepaint()
# Optional: confirm the tree built correctly
for child in layer.labeling().rootRule().children():
print(child.description(), "->", child.filterExpression())
Breakdown: QgsRuleBasedLabeling(root) takes ownership of the rule tree. setLabelsEnabled(True) is mandatory — without it the rules exist but draw nothing. The verification loop reads the rules back from layer.labeling().rootRule().children(), a handy sanity check when a script builds rules dynamically. Because rule filters use the same expression engine as everywhere else in QGIS, you can reference any field or function you would use when you set a vector layer's symbol color in PyQGIS.
Combining Filters with Other Attributes
Rules are not limited to a single field. Combine class and importance so, for example, only named major roads are labeled at small scales:
major = QgsRuleBasedLabeling.Rule(road_label_settings("name", 10, "#7c2d12"))
major.setFilterExpression(
"\"highway\" IN ('motorway', 'trunk', 'primary') "
"AND \"name\" IS NOT NULL AND length(\"name\") > 0"
)
major.setMaximumScale(500000)
root.appendChild(major)
This same conditional thinking drives thematic styling; if you classify the underlying polygons too, pair this with Create a Choropleth Map in PyQGIS so labels and fill colors describe the same classes.
Setting Priority Per Rule
Scale ranges decide whether a rule is eligible at the current zoom, but when several eligible labels compete for the same space the placement engine drops some. Each rule's settings object carries its own priority, so you control which classes survive crowding. Give the most important roads the highest priority.
# Inside road_label_settings, or applied after construction:
motorway_settings = road_label_settings("name", 11, "#7c2d12")
motorway_settings.priority = 10 # never sacrificed
secondary_settings = road_label_settings("name", 9, "#1f2937")
secondary_settings.priority = 6
residential_settings = road_label_settings("name", 8, "#374151")
residential_settings.priority = 3 # first to go when crowded
motorway = QgsRuleBasedLabeling.Rule(motorway_settings)
secondary = QgsRuleBasedLabeling.Rule(secondary_settings)
residential = QgsRuleBasedLabeling.Rule(residential_settings)
Breakdown: Priority runs 0 (lowest) to 10 (highest). When the engine cannot place every candidate, it discards low-priority labels first, so the motorway names at priority 10 persist while priority-3 residential streets vanish in dense areas. Priority and scale range work together: scale range removes a rule from contention entirely outside its zoom band, while priority arbitrates among the rules that remain. Tuning these two settings, rather than disabling collision detection, is how you keep a crowded street map legible.
Updating a Single Rule Later
Because the rule tree is a mutable object, you can adjust one branch without rebuilding everything. Find a rule by its description (or iterate the tree) and change its filter, scale, or settings in place, then repaint.
labeling = layer.labeling()
for rule in labeling.rootRule().children():
if rule.description() == "Residential streets":
rule.setMaximumScale(15000) # show even closer in only
layer.setLabeling(labeling.clone())
layer.triggerRepaint()
Breakdown: Mutating the rule changes the live tree, but reinstalling a clone() is the reliable way to force the layer to pick up the change across all QGIS versions in scope. This targeted-edit pattern keeps interactive tools responsive — a slider that controls when minor roads appear only needs to touch one rule, not regenerate the entire labeling.
QGIS Version Compatibility
All examples target QGIS 3.34 LTR (Python 3.12) as the baseline. The QgsRuleBasedLabeling API is stable across the supported range.
| Component | 3.28 LTR | 3.34 LTR (baseline) | 3.40 / 3.44 |
|---|---|---|---|
QgsRuleBasedLabeling.Rule | available | available | available |
setFilterExpression / appendChild | available | available | available |
setMaximumScale / setMinimumScale | available | available | available |
Placement enums (QgsPalLayerSettings.Curved) | scoped names | scoped names | scoped + Qgis.LabelPlacement |
On QGIS 3.28 the only practical difference is Python 3.9 versus 3.12 — no labeling code changes are required. Avoid Qgis.LabelPlacement enums unless you have dropped 3.28 support.
Troubleshooting
No labels appear after running the script.
Confirm you called layer.setLabelsEnabled(True) after setLabeling(). Then check that at least one rule's filter actually matches features (test the expression in the field calculator) and that the current map scale falls inside a rule's scale range.
Labels show at the wrong zoom levels.
This is almost always the scale-method inversion. setMaximumScale controls the most-zoomed-out limit (largest denominator) and setMinimumScale the most-zoomed-in limit. If a rule never shows when zoomed out, raise its maximumScale denominator.
TypeError when constructing the root rule.
Pass None to QgsRuleBasedLabeling.Rule(None) for the container root, not an empty QgsPalLayerSettings(). Child rules, by contrast, must receive a real settings object.
Some matching features stay unlabeled at busy scales.
That is the collision engine dropping overlaps, not a rule failure. Raise priority on the important rule's settings or reduce font size; see the placement and obstacle discussion in the parent cluster.
Conclusion
Rule-based labeling turns a flat label layer into a scale-aware, class-aware map. Build a factory for your settings, assemble a root rule with filtered, scale-bounded children, then install it with the standard setLabeling / setLabelsEnabled / triggerRepaint trio. Because every rule speaks the QGIS expression language, the same technique extends to any attribute logic your data requires.
Frequently Asked Questions
Can a rule have child rules of its own (nested deeper than one level)?
Yes. appendChild works at any depth, and filters compound — a child only applies to features that already passed its parent's filter. Use nesting to factor a shared filter (e.g. "named roads") into a parent and vary scale or style in the children.
How do I convert existing simple labeling into rule-based labeling?
Read the current settings with layer.labeling().settings(), wrap them in a QgsRuleBasedLabeling.Rule, append that to a new root, and reinstall. There is no automatic one-call converter, but reusing the existing QgsPalLayerSettings makes it a few lines.
Do rule scale ranges interact with the layer's own scale-dependent visibility? They are independent. The layer can be hidden entirely by its visibility scale range, while each label rule has its own range applied only when the layer is drawn. Both must permit the current scale for a label to show.