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_geometry prevents mismatched symbol types (e.g., applying line symbology to points).
  • fallback_rule ensures unclassified features render consistently.
  • filter uses native QGIS expression syntax, enabling spatial, attribute, and function-based logic.
  • label is 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() or ilike().
  • Null handling: Use COALESCE() or IS NOT NULL to 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 jsonschema to enforce structure before PyQGIS execution.
  • Export final maps via QgsLayoutExporter or QgsMapRendererJob for headless PDF/PNG generation.

Best Practices & Limitations

  • Keep rules flat: Deeply nested QgsRuleBasedRenderer hierarchies 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 version field 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.