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:

  1. Load and parse the JSON style definition — read the file, validate the top-level schema (version string, geometry type, rules array).
  2. Validate geometry type — compare QgsWkbTypes against target_geometry and raise early if they diverge; mismatched types produce silent rendering failures.
  3. Validate filter expressions — pass every filter string through QgsExpression and call hasParserError() before constructing any symbol objects.
  4. Build the renderer — iterate the rules array, construct geometry-specific QgsSymbol objects via setter methods (not constructors), and attach them to a QgsRuleBasedRenderer rule tree.
  5. Apply and repaint — call layer.setRenderer(), optionally configure QgsPalLayerSettings for labeling, then call layer.triggerRepaint().

The diagram below shows the data flow from JSON file to rendered layer:

JSON Rule Styling Engine Pipeline Data-flow diagram showing the five stages of the JSON rule styling engine: load JSON, validate geometry type, validate filter expressions, build QgsRuleBasedRenderer, apply renderer and repaint layer. Load JSON json.load() Validate geometry type Validate expressions Build renderer Apply + repaint ValueError ValueError styles.json QgsLayer JSON rule engine — five-stage pipeline

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_geometry prevents mismatched symbol types before any QGIS API call is made.
  • fallback_rule with "filter": "true" ensures unclassified features always render, exposing coverage gaps visually rather than silently.
  • filter uses native QGIS expression syntax — spatial predicates, aggregates, and user-defined functions all work without modification.
  • Every filter on a nullable field should include IS NOT NULL so NULL values do not silently drop rule matches (NULL evaluated in a boolean context returns NULL, not FALSE).
  • label is 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. QgsRuleBasedRenderer evaluates rules depth-first and stops at the first match per feature (unless you enable else rules). 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 a dist_to_centroid column via GDAL/OGR or GeoPandas before loading the layer.
  • Validate JSON at schema-level before PyQGIS. Add a jsonschema validation step before calling apply_json_style(). Catching a missing "filter" key at the schema stage produces a far clearer error than a KeyError inside a QGIS repaint callback.
  • Use QgsRuleBasedLabeling for per-rule label control. The implementation above applies one label configuration to the whole layer. QGIS 3.12+ exposes QgsRuleBasedLabeling, which mirrors the renderer’s rule tree and lets each rule carry independent QgsPalLayerSettings, 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 version field. Bump the minor version when adding optional fields, and the major version for breaking changes (e.g., renaming stroke_width to outline_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.