Building a JSON-Based Rule Styling Engine for QGIS
A JSON-based rule styling engine for QGIS decouples cartographic design from Python logic by mapping a declarative JSON file directly to the QgsRuleBasedRenderer API — so symbology becomes version-controlled configuration rather than hardcoded script state.
Core Algorithm and Workflow
The engine performs five discrete operations on every invocation. Understanding the dependency chain prevents the most common failure modes:
- Load and parse the JSON style definition — read the file, validate the top-level schema (version string, geometry type, rules array).
- Validate geometry type — compare
QgsWkbTypesagainsttarget_geometryand raise early if they diverge; mismatched types produce silent rendering failures. - Validate filter expressions — pass every
filterstring throughQgsExpressionand callhasParserError()before constructing any symbol objects. - Build the renderer — iterate the rules array, construct geometry-specific
QgsSymbolobjects via setter methods (not constructors), and attach them to aQgsRuleBasedRendererrule tree. - Apply and repaint — call
layer.setRenderer(), optionally configureQgsPalLayerSettingsfor labeling, then calllayer.triggerRepaint().
The diagram below shows the data flow from JSON file to rendered layer:
This architecture aligns with the broader rule-based styling engines pattern where styling logic lives in version-controlled configuration files and Python executes it without encoding design decisions.
JSON Schema Design
The schema must be geometry-aware and self-describing. Each rule object carries a filter in native QGIS expression syntax, a symbol dictionary, and an optional label block. Standardising the schema lets you swap style definitions without touching the Python parser.
A production-ready schema:
{
"version": "1.0",
"target_geometry": "polygon",
"fallback_rule": {
"filter": "true",
"symbol": {"color": "#CCCCCC", "stroke_width": 0.5, "style": "solid"}
},
"rules": [
{
"name": "Residential Zones",
"filter": "\"land_use\" IS NOT NULL AND \"land_use\" = 'residential'",
"symbol": {"color": "#E6E6FA", "stroke_width": 0.5, "style": "solid"},
"label": {"field": "zone_name", "size": 10, "color": "#333333", "placement": "center"}
},
{
"name": "Industrial Zones",
"filter": "\"land_use\" IS NOT NULL AND \"land_use\" = 'industrial'",
"symbol": {"color": "#A9A9A9", "stroke_width": 0.5, "style": "solid"}
}
]
}
Key schema decisions:
target_geometryprevents mismatched symbol types before any QGIS API call is made.fallback_rulewith"filter": "true"ensures unclassified features always render, exposing coverage gaps visually rather than silently.filteruses native QGIS expression syntax — spatial predicates, aggregates, and user-defined functions all work without modification.- Every
filteron a nullable field should includeIS NOT NULLso NULL values do not silently drop rule matches (NULL evaluated in a boolean context returns NULL, not FALSE). labelis optional; omitting it inherits the layer’s existing labeling or leaves that rule’s features unlabeled.
Production-Ready Python Implementation
The following apply_json_style() function is self-contained and tested against QGIS 3.28 LTR. It follows the five-stage pipeline: load, validate geometry, validate expressions, build renderer, apply.
Critical note before reading the code: QgsSimpleFillSymbolLayer, QgsSimpleLineSymbolLayer, and QgsSimpleMarkerSymbolLayer constructors do not reliably accept color or width as positional arguments across QGIS 3.x Python bindings. Always call setter methods (setFillColor(), setStrokeWidth(), etc.) after construction — the code below demonstrates the correct pattern.
import json
from qgis.core import (
QgsProject,
QgsRuleBasedRenderer,
QgsSymbol,
QgsWkbTypes,
QgsSimpleFillSymbolLayer,
QgsSimpleLineSymbolLayer,
QgsSimpleMarkerSymbolLayer,
QgsExpression,
QgsPalLayerSettings,
QgsVectorLayerSimpleLabeling,
QgsTextFormat,
)
from PyQt5.QtGui import QColor
def _build_symbol_layer(symbol_props: dict, geom_type: int):
"""
Construct a geometry-specific symbol layer using setter methods.
Do NOT pass color/width to constructors — QGIS 3.x bindings ignore them.
"""
color = QColor(symbol_props.get("color", "#888888"))
stroke_width = float(symbol_props.get("stroke_width", 0.5))
if geom_type == QgsWkbTypes.PolygonGeometry:
sl = QgsSimpleFillSymbolLayer()
sl.setFillColor(color)
sl.setStrokeWidth(stroke_width)
elif geom_type == QgsWkbTypes.LineGeometry:
sl = QgsSimpleLineSymbolLayer()
sl.setColor(color)
sl.setWidth(stroke_width)
else: # PointGeometry
sl = QgsSimpleMarkerSymbolLayer()
sl.setColor(color)
sl.setSize(float(symbol_props.get("size", 2.0)))
return sl
def apply_json_style(layer, json_path: str) -> None:
"""
Parse a JSON style definition and apply it to a QgsVectorLayer.
Args:
layer: A QgsVectorLayer — must already be loaded and valid.
json_path: Absolute path to the JSON style file.
Raises:
ValueError: On geometry mismatch or invalid QGIS expression syntax.
"""
with open(json_path, "r", encoding="utf-8") as fh:
style_data = json.load(fh)
# --- Stage 1: Validate geometry type ---
geom_type = layer.geometryType()
expected = style_data.get("target_geometry", "").lower()
geom_map = {
"point": QgsWkbTypes.PointGeometry,
"line": QgsWkbTypes.LineGeometry,
"polygon": QgsWkbTypes.PolygonGeometry,
}
if expected not in geom_map:
raise ValueError(f"Unknown target_geometry '{expected}' in {json_path}")
if geom_type != geom_map[expected]:
raise ValueError(
f"Layer geometry ({geom_type}) does not match JSON "
f"target_geometry='{expected}' in {json_path}"
)
# --- Stage 2: Validate all expressions before building anything ---
rules_to_build = list(style_data.get("rules", []))
fallback = style_data.get("fallback_rule")
if fallback:
rules_to_build.append({**fallback, "name": "Fallback"})
for rule_def in rules_to_build:
expr = QgsExpression(rule_def["filter"])
if expr.hasParserError():
raise ValueError(
f"Expression parser error in rule '{rule_def['name']}': "
f"{expr.parserErrorString()}"
)
# --- Stage 3: Build QgsRuleBasedRenderer ---
renderer = QgsRuleBasedRenderer(QgsSymbol.defaultSymbol(geom_type))
root_rule = renderer.rootRule()
for rule_def in style_data.get("rules", []):
symbol = QgsSymbol.defaultSymbol(geom_type)
symbol.changeSymbolLayer(
0, _build_symbol_layer(rule_def["symbol"], geom_type)
)
rule = QgsRuleBasedRenderer.Rule(
symbol, 0, 0, rule_def["filter"], rule_def["name"]
)
root_rule.appendChild(rule)
if fallback:
fb_symbol = QgsSymbol.defaultSymbol(geom_type)
fb_symbol.changeSymbolLayer(
0, _build_symbol_layer(fallback["symbol"], geom_type)
)
fb_rule = QgsRuleBasedRenderer.Rule(
fb_symbol, 0, 0, fallback["filter"], "Fallback"
)
root_rule.appendChild(fb_rule)
# --- Stage 4: Apply renderer ---
layer.setRenderer(renderer)
# --- Stage 5: Optional rule-level labeling ---
# Apply the first rule's label config to the whole layer.
# For per-rule labels use QgsRuleBasedLabeling (QGIS 3.12+).
labeled_rules = [r for r in style_data.get("rules", []) if r.get("label")]
if labeled_rules:
lbl_cfg = labeled_rules[0]["label"]
settings = QgsPalLayerSettings()
settings.fieldName = lbl_cfg.get("field", "id")
fmt = QgsTextFormat()
fmt.setSize(float(lbl_cfg.get("size", 10)))
fmt.setColor(QColor(lbl_cfg.get("color", "#000000")))
settings.setFormat(fmt)
layer.setLabelsEnabled(True)
layer.setLabeling(QgsVectorLayerSimpleLabeling(settings))
layer.triggerRepaint()
print(
f"[apply_json_style] Applied {len(style_data['rules'])} rule(s) "
f"+ fallback to layer '{layer.name()}'"
)
The function raises immediately on the first problem it finds, so batch jobs that wrap it in try/except can log failures without processing corrupted layer state.
Performance Tuning and Cartographic Best Practices
- Keep rules mutually exclusive and flat.
QgsRuleBasedRendererevaluates rules depth-first and stops at the first match per feature (unless you enableelserules). Deeply nested trees or overlapping filters cause double-evaluation; encode your logic as peer-level mutually exclusive expressions. - Avoid geometry-intensive expressions in filters. Predicates like
distance($geometry, make_point(…))run per-feature at render time. Precompute such attributes in your data preparation step — for example, add adist_to_centroidcolumn via GDAL/OGR or GeoPandas before loading the layer. - Validate JSON at schema-level before PyQGIS. Add a
jsonschemavalidation step before callingapply_json_style(). Catching a missing"filter"key at the schema stage produces a far clearer error than aKeyErrorinside a QGIS repaint callback. - Use
QgsRuleBasedLabelingfor per-rule label control. The implementation above applies one label configuration to the whole layer. QGIS 3.12+ exposesQgsRuleBasedLabeling, which mirrors the renderer’s rule tree and lets each rule carry independentQgsPalLayerSettings, enabling distinct font sizes or placements by feature class — directly analogous to how dynamic legend generation reflects per-class symbology in an exported legend. - Semantic-version the JSON
versionfield. Bump the minor version when adding optional fields, and the major version for breaking changes (e.g., renamingstroke_widthtooutline_width). The parser can read the field and warn on version mismatches before applying potentially incompatible rules.
Integration and Next Steps
Once validated, apply_json_style() slots into several pipeline configurations:
QGIS Python console — the simplest path: paste the function, point it at a JSON file on disk, and call it on any active layer. Good for design iteration.
Standalone headless PyQGIS script — initialise the application stack before any API call:
import os, sys
from qgis.core import QgsApplication, QgsVectorLayer
os.environ["QGIS_PREFIX_PATH"] = "/usr" # adjust to your installation
app = QgsApplication([], False)
app.initQgis()
layer = QgsVectorLayer("/data/land_use.gpkg|layername=zones", "zones", "ogr")
apply_json_style(layer, "/styles/land_use_rules.json")
# Export via QgsMapRendererParallelJob or QgsLayoutExporter …
app.exitQgis()
Batch processing — wrap the call in a loop over a directory of GeoPackages and catch per-file errors without halting the job:
from pathlib import Path
gpkg_dir = Path("/data/tiles/")
style_path = "/styles/land_use_rules.json"
failures = []
for gpkg in sorted(gpkg_dir.glob("*.gpkg")):
lyr = QgsVectorLayer(f"{gpkg}|layername=zones", gpkg.stem, "ogr")
if not lyr.isValid():
failures.append((gpkg, "invalid layer"))
continue
try:
apply_json_style(lyr, style_path)
export_png(lyr, gpkg.with_suffix(".png")) # your export function
except (ValueError, OSError) as exc:
failures.append((gpkg, str(exc)))
for path, reason in failures:
print(f"FAILED {path}: {reason}")
CI/CD integration — store JSON style files in the same Git repository as your vector data. Pull requests against a style file become auditable style changes. A CI step can run apply_json_style() in a headless Docker container and diff exported PNGs against reference renders to catch unintended visual regressions. This pattern scales naturally into the broader programmatic map styling and label automation pipeline that drives enterprise cartographic production.
For downstream label placement after styling is applied, feeding the rendered layer into a system implementing the label collision avoidance algorithms used in automated urban-density maps will prevent text overlap when rule-based classes produce densely placed features.
Frequently Asked Questions
Why does QgsSimpleFillSymbolLayer ignore the color I pass to its constructor?
In QGIS 3.x the C++ default-argument values in symbol-layer constructors are not uniformly exposed through the Python bindings — the constructor appears to accept arguments but the layer is created with its built-in defaults. Always use the setter methods (setFillColor(), setStrokeWidth(), etc.) immediately after construction; the pattern above is the correct idiom for all QGIS 3.x minor versions.
How do I run this outside the QGIS desktop application?
Call QgsApplication.initQgis() before any QGIS API access and QgsApplication.exitQgis() on shutdown. Set the QGIS_PREFIX_PATH environment variable to your QGIS installation prefix so that data-provider plugins (GDAL, OGR, PostGIS) are discovered. The standalone example in “Integration and Next Steps” above shows the minimal bootstrap.
What is the correct way to handle NULL attribute values in filter expressions?
NULL values cause a QGIS expression to evaluate to NULL (not FALSE), which means the feature matches no rule and falls through to the fallback. If you want to classify NULLs explicitly, add a dedicated rule with "filter": "\"land_use\" IS NULL". If you want NULLs to match the fallback silently, add IS NOT NULL guards on all other rules, as the schema example above demonstrates.
Related
- Back to Rule-Based Styling Engines — the parent guide covering the full architecture of condition-driven symbology systems.
- Programmatic Map Styling and Label Automation — the section covering all automation techniques for symbology, labeling, and legend production.
- Dynamic Legend Generation — automate legend creation that mirrors your rule-based symbology at export time.
- Label Collision Avoidance Algorithms — pair with this engine to prevent text overlap on densely classified feature classes.