Building a JSON-Based Rule Styling Engine for QGIS
Building a JSON-based rule styling engine for QGIS decouples cartographic design from Python logic by mapping declarative JSON objects directly to the QgsRuleBasedRenderer API. Instead of hardcoding symbology in scripts, you define filter expressions, symbol properties, and label configurations in a version-controlled JSON file. A lightweight Python parser validates the rules, instantiates QgsSymbol objects, and attaches them to vector layers. This architecture aligns with modern Programmatic Map Styling and Label Automation workflows, enabling batch processing, CI/CD map generation, and consistent styling across headless and desktop environments.
JSON Schema Architecture
The engine relies on a predictable, geometry-aware JSON structure. Each rule object contains a filter (using QGIS expression syntax), a symbol dictionary (geometry-specific properties), and an optional label configuration. Standardizing this schema allows cartographers to swap style definitions without modifying Python logic.
A minimal, production-ready schema:
{
"version": "1.0",
"target_geometry": "polygon",
"fallback_rule": {
"filter": "true",
"symbol": {"color": "#CCCCCC", "width": 0.5, "style": "solid"}
},
"rules": [
{
"name": "Residential Zones",
"filter": "\"land_use\" = 'residential'",
"symbol": {"color": "#E6E6FA", "width": 0.5, "style": "solid"},
"label": {"field": "zone_name", "size": 10, "color": "#333333", "placement": "center"}
},
{
"name": "Industrial Zones",
"filter": "\"land_use\" = 'industrial'",
"symbol": {"color": "#A9A9A9", "width": 0.5, "style": "solid"}
}
]
}
Key schema decisions:
target_geometryprevents mismatched symbol types (e.g., applying line symbology to points).fallback_ruleensures unclassified features render consistently.filteruses native QGIS expression syntax, enabling spatial, attribute, and function-based logic.labelis optional; omitting it defaults to the layer’s existing labeling or disables labels for that rule.
Complete PyQGIS Implementation
The following script demonstrates a complete, runnable engine. It parses the JSON, validates expressions, constructs symbols, and applies the renderer to an active layer. It is tested against QGIS 3.28+ LTR and uses the stable QgsSymbol factory pattern.
import json
from qgis.core import (
QgsProject, QgsRuleBasedRenderer, QgsSymbol, QgsWkbTypes,
QgsSimpleFillSymbolLayer, QgsSimpleLineSymbolLayer, QgsSimpleMarkerSymbolLayer,
QgsExpression, QgsExpressionContextUtils, QgsPalLayerSettings,
QgsVectorLayerSimpleLabeling, QgsTextFormat, QgsTextBufferSettings
)
from PyQt5.QtGui import QColor
def apply_json_style(layer, json_path):
with open(json_path, 'r') as f:
style_data = json.load(f)
# 1. Validate geometry match
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 or geom_type != geom_map[expected]:
raise ValueError(f"Layer geometry ({geom_type}) does not match JSON target ({expected})")
# 2. Initialize renderer
renderer = QgsRuleBasedRenderer(QgsSymbol.defaultSymbol(geom_type))
root_rule = renderer.rootRule()
# 3. Helper: Build symbol layer based on geometry
def create_symbol_layer(symbol_props, geom_t):
if geom_t == QgsWkbTypes.PolygonGeometry:
return QgsSimpleFillSymbolLayer(
QColor(symbol_props.get("color", "#000000")),
symbol_props.get("style", "solid"),
float(symbol_props.get("width", 0.5))
)
elif geom_t == QgsWkbTypes.LineGeometry:
return QgsSimpleLineSymbolLayer(
QColor(symbol_props.get("color", "#000000")),
float(symbol_props.get("width", 0.5)),
symbol_props.get("style", "solid")
)
else: # Point
return QgsSimpleMarkerSymbolLayer(
symbol_props.get("style", "circle"),
QColor(symbol_props.get("color", "#000000")),
float(symbol_props.get("width", 2.0))
)
# 4. Parse and attach rules
expr_context = QgsExpressionContextUtils.globalProjectLayerContext(layer)
for rule_def in style_data.get("rules", []):
expr = QgsExpression(rule_def["filter"])
if expr.hasParserError():
raise ValueError(f"Invalid expression in rule '{rule_def['name']}': {expr.parserErrorString()}")
symbol = QgsSymbol.defaultSymbol(geom_type)
symbol.changeSymbolLayer(0, create_symbol_layer(rule_def["symbol"], geom_type))
rule = QgsRuleBasedRenderer.Rule(symbol, 0, 0, rule_def["filter"], rule_def["name"])
root_rule.appendChild(rule)
# 5. Attach fallback rule
fallback = style_data.get("fallback_rule")
if fallback:
fb_symbol = QgsSymbol.defaultSymbol(geom_type)
fb_symbol.changeSymbolLayer(0, create_symbol_layer(fallback["symbol"], geom_type))
fb_rule = QgsRuleBasedRenderer.Rule(fb_symbol, 0, 0, fallback["filter"], "Fallback")
root_rule.appendChild(fb_rule)
# 6. Apply renderer & optional labels
layer.setRenderer(renderer)
if any(r.get("label") for r in style_data.get("rules", [])):
lbl_cfg = style_data["rules"][0]["label"] # Use first rule's label config as baseline
settings = QgsPalLayerSettings()
settings.fieldName = lbl_cfg.get("field", "id")
settings.placement = lbl_cfg.get("placement", "over_point")
fmt = QgsTextFormat()
fmt.setSize(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"✅ Applied {len(style_data['rules'])} rules to {layer.name()}")
Expression Validation & Error Handling
Production styling engines must fail fast. The script above uses QgsExpression.hasParserError() to catch malformed syntax before it crashes QGIS. For deeper validation, you can evaluate expressions against a dummy feature to catch runtime errors like missing fields or type mismatches. Refer to the official QGIS Expression Engine documentation for supported functions and syntax rules.
Common validation pitfalls:
- Unquoted field names: QGIS requires double quotes for field references (
"population" > 100). - Case sensitivity: String comparisons are case-sensitive unless wrapped in
lower()orilike(). - Null handling: Use
COALESCE()orIS NOT NULLto prevent silent rule drops.
Pipeline Integration & Automation
Once validated, the engine integrates seamlessly into automated workflows. You can run it via the QGIS Python console, standalone PyQGIS scripts, or Dockerized headless environments. For batch processing, iterate through a directory of shapefiles or GeoPackages, load each layer, and apply the same JSON style sheet. This approach is foundational for scalable Rule-Based Styling Engines used by mapping agencies and data publishers.
Deployment checklist:
- Use
QgsApplication.initQgis()before running standalone scripts. - Wrap
apply_json_style()in a try/except block to log failures without halting batch jobs. - Cache validated JSON schemas using
jsonschemato enforce structure before PyQGIS execution. - Export final maps via
QgsLayoutExporterorQgsMapRendererJobfor headless PDF/PNG generation.
Best Practices & Limitations
- Keep rules flat: Deeply nested
QgsRuleBasedRendererhierarchies degrade performance. Flatten logic into mutually exclusive filters where possible. - Avoid heavy functions in filters: Expressions like
distance($geometry, make_point(...))evaluate per feature. Precompute attributes in the data pipeline instead. - Symbol layer limits: QGIS supports multiple symbol layers per rule for complex cartography (e.g., hatching + outline). Extend
create_symbol_layer()to accept arrays if needed. - Version control: Store JSON styles alongside project data in Git. Use semantic versioning in the JSON
versionfield to track breaking changes.
This engine provides a deterministic, code-free styling workflow that scales from single-layer desktop maps to enterprise cartographic pipelines. By externalizing rules into JSON, teams can collaborate on design, audit changes via pull requests, and deploy consistent styling across distributed QGIS environments.